diff --git a/.githooks/sync-versions.test.ts b/.githooks/sync-versions.test.ts index 2e8c6b69..451235a0 100644 --- a/.githooks/sync-versions.test.ts +++ b/.githooks/sync-versions.test.ts @@ -35,6 +35,11 @@ function createFixtureRepo(): string { name: '@truenine/memory-sync-cli', version: initialVersion }) + writeJson(join(rootDir, 'sdk', 'package.json'), { + name: '@truenine/memory-sync-sdk', + version: initialVersion, + private: true + }) writeJson(join(rootDir, 'cli', 'npm', 'darwin-arm64', 'package.json'), { name: '@truenine/memory-sync-cli-darwin-arm64', version: initialVersion @@ -101,6 +106,43 @@ describe('sync-versions hook', () => { expect(result.versionSource).toBe('cli/npm/darwin-arm64/package.json') expect(JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) expect(JSON.parse(readFileSync(join(rootDir, 'cli', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) + expect(JSON.parse(readFileSync(join(rootDir, 'sdk', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) + expect(JSON.parse(readFileSync(join(rootDir, 'libraries', 'logger', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) + expect(readFileSync(join(rootDir, 'Cargo.toml'), 'utf-8')).toContain(`version = "${nextVersion}"`) + expect(readFileSync(join(rootDir, 'cli-crate', 'Cargo.toml'), 'utf-8')).toContain(`version = "${nextVersion}"`) + expect(JSON.parse(readFileSync(join(rootDir, 'gui', 'src-tauri', 'tauri.conf.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) + expect(stagedFiles).toEqual(new Set([ + 'Cargo.toml', + 'cli-crate/Cargo.toml', + 'cli/npm/darwin-arm64/package.json', + 'cli/package.json', + 'gui/src-tauri/tauri.conf.json', + 'libraries/logger/package.json', + 'package.json', + 'sdk/package.json' + ])) + }) + + it('accepts sdk/package.json as a staged version source and propagates it', () => { + const rootDir = createFixtureRepo() + tempDirs.push(rootDir) + + const nextVersion = '2026.10324.10316' + writeJson(join(rootDir, 'sdk', 'package.json'), { + name: '@truenine/memory-sync-sdk', + version: nextVersion, + private: true + }) + runGit(rootDir, ['add', 'sdk/package.json']) + + const result = runSyncVersions({rootDir}) + const stagedFiles = new Set(runGit(rootDir, ['diff', '--cached', '--name-only']).split(/\r?\n/).filter(Boolean)) + + expect(result.targetVersion).toBe(nextVersion) + expect(result.versionSource).toBe('sdk/package.json') + expect(JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) + expect(JSON.parse(readFileSync(join(rootDir, 'cli', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) + expect(JSON.parse(readFileSync(join(rootDir, 'sdk', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) expect(JSON.parse(readFileSync(join(rootDir, 'libraries', 'logger', 'package.json'), 'utf-8')) as {version: string}).toMatchObject({version: nextVersion}) expect(readFileSync(join(rootDir, 'Cargo.toml'), 'utf-8')).toContain(`version = "${nextVersion}"`) expect(readFileSync(join(rootDir, 'cli-crate', 'Cargo.toml'), 'utf-8')).toContain(`version = "${nextVersion}"`) @@ -112,7 +154,8 @@ describe('sync-versions hook', () => { 'cli/package.json', 'gui/src-tauri/tauri.conf.json', 'libraries/logger/package.json', - 'package.json' + 'package.json', + 'sdk/package.json' ])) }) diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 5c262982..413ca103 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -239,7 +239,7 @@ jobs: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-node-pnpm with: - install: "false" + install: "true" - name: Setup npm registry uses: actions/setup-node@v6 with: @@ -299,6 +299,16 @@ jobs: echo "Copying from ${artifact_dir} to ${target_dir}" cp "${artifact_dir}"*.node "$target_dir/" || { echo "ERROR: no .node files found in ${artifact_dir}"; exit 1; } done + - name: Generate CLI platform package shims + shell: bash + run: | + shopt -s nullglob + dirs=(cli/npm/*/) + if [ "${#dirs[@]}" -eq 0 ]; then + echo "No CLI platform package directories found" + exit 0 + fi + pnpm exec tsx scripts/write-platform-package-shims.ts "${dirs[@]}" - name: Validate CLI platform packages shell: bash run: | @@ -314,12 +324,12 @@ jobs: if [ ! -f "${target_dir}package.json" ]; then continue fi - if [ ! -f "${target_dir}noop.cjs" ]; then - echo "ERROR: missing ${target_dir}noop.cjs" + if [ ! -f "${target_dir}noop.mjs" ]; then + echo "ERROR: missing ${target_dir}noop.mjs" exit 1 fi - if [ ! -f "${target_dir}noop.d.ts" ]; then - echo "ERROR: missing ${target_dir}noop.d.ts" + if [ ! -f "${target_dir}noop.d.mts" ]; then + echo "ERROR: missing ${target_dir}noop.d.mts" exit 1 fi actual_count=$(find "${target_dir}" -maxdepth 1 -type f -name '*.node' | wc -l | tr -d ' ') diff --git a/Cargo.lock b/Cargo.lock index 601b13e0..85bdcdd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4456,11 +4456,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tnmsc-cli-shell" +version = "2026.10330.118" +dependencies = [ + "clap", + "serde_json", + "tnmsc", + "tnmsc-logger", +] + [[package]] name = "tnmsc-logger" version = "2026.10330.118" dependencies = [ - "chrono", "napi", "napi-build", "napi-derive", diff --git a/Cargo.toml b/Cargo.toml index 6cc71d83..2973f149 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "sdk", "cli", "libraries/logger", "libraries/md-compiler", @@ -9,7 +10,7 @@ members = [ ] [workspace.package] -version = "2026.10330.118" +version = "2026.10402.103" edition = "2024" rust-version = "1.88" license = "AGPL-3.0-only" @@ -18,7 +19,7 @@ repository = "https://github.com/TrueNine/memory-sync" [workspace.dependencies] # Internal crates -tnmsc = { path = "cli" } +tnmsc = { path = "sdk" } tnmsc-logger = { path = "libraries/logger" } tnmsc-md-compiler = { path = "libraries/md-compiler" } tnmsc-script-runtime = { path = "libraries/script-runtime" } diff --git a/README.md b/README.md index 93ba46f6..fcf40e8b 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,96 @@ # memory-sync -Rats 🐀 are like this: even our own brains, even our memories, are things we haul around while running through this fucked-up world!!! +A rat is like this. Even its own brain, even its memory, has to be carried around in this rotten world!!! -I am a rat. No resources will ever be proactively provided to me. -So as a rat, I eat whatever I can reach: maggots in the sewer, leftovers in the slop bucket, and in extreme cases even my own kind—this is the survival mode in a world where resource allocation is brutally unfair. +I am a rat. No resource is going to walk up and offer itself to me. +So as a rat, I eat whatever I can reach: maggots in the sewer, leftovers in a slop bucket, and in extreme cases even my own kind. That is what survival looks like in a world where resource distribution is brutally unequal. `memory-sync` is the same kind of **tool-rat**: -- 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.** +- It does not wait for any platform to hand out an "official all-in-one solution" +- It does not rely on the privileged interface of any single IDE or CLI +- Any configuration, prompt, memory file, or generated artifact it can read becomes something edible: something to haul away, break apart, and recombine +In this ecosystem, the giants hoard the resources while developers get thrown into a corner like rats. +`memory-sync` accepts this cruel reality, does not fantasize about fairness, and focuses on one thing only: **to chew through every fragment of resource you already have and turn it into portable "memory" that can flow between AI tools.** ![rat](/.attachments/rat.svg) -What can it help you do? - -- **`.mdx` as the prompt source format**: write your prompts in MDX; `memory-sync` reads, transforms, and writes them into each tool's native config format—you maintain one source, it handles the rest. -- A **universal prompt spec**: write Global / Root / Child / Skill / Command / Agent prompts in a unified structure. -- **Auto-write tool config files**: AGENTS.md, .cursorrules, .kiro/, CLAUDE.md, etc.—if there is an entry point, it stuffs your memory in. -- **Generate copy-ready one-shot prompts**: package project context, tech stack, and current task into AI-friendly Markdown, paste into any chat box directly. -- Like a rat gnawing on cables, **gnaw structured memory out of existing directory structures and config files**, instead of asking you to rewrite everything from scratch. -- **Fine-grained control**: describe rules in YAML / JSON config files, choose what to sync by project, by Agent, by tool type—no "one-size-fits-all" overwrites. -- **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. +What can it do for you? + +- **Use `.mdx` / `.src.mdx` as the source of truth**: you maintain one source, and `memory-sync` turns it into native tool configs plus managed generated artifacts. +- **Use one unified input-asset model**: Global / Workspace / Project Memory, Skills, Commands, Sub-agents, Rules, README-like outputs, and related assets all fit into one structure. +- **Auto-write native tool configs**: AGENTS.md, Claude Code CLI, Codex CLI, Cursor, Windsurf, Qoder, Trae, Warp, JetBrains AI Assistant Codex, and more. If a native entry point exists, it can write there. +- **Manage derived artifacts**: besides target-tool configs, it can maintain English prompt outputs, skill exports, README-like outputs, and other helper configs. +- **Provide multiple entry points**: the public entry is the `tnmsc` CLI; internally there is also a private SDK, an MCP stdio server, and a Tauri GUI, all working around the same source-of-truth model. +- **Control write scope precisely**: use `outputScopes`, `cleanupProtection`, and related settings to constrain writes and cleanup by project, topic, and tool. +- **Keep source and derived outputs auditable**: source files, generated artifacts, and target-tool configs stay clearly separated. No hidden source edits. No hidden residue. +- **Let memory grow with you**: memory follows you as a person instead of leaking with the project. If a project changes hands, they do not get your context. If you move to another project, your accumulated memory goes with you unchanged. ## Install ```sh -npm install -g @truenine/memory-sync +npm install -g @truenine/memory-sync-cli ``` -## Docs +Optional MCP server: -`https://docs.truenine.org/tnmsc` +```sh +npm install -g @truenine/memory-sync-mcp +``` ## Supported Tools | Type | Tools | | --- | --- | -| IDE | Cursor, Kiro, Windsurf, JetBrains AI | -| CLI | Claude CLI, Gemini CLI, Codex CLI, Warp | +| IDE / Editor | Cursor, Windsurf, Qoder, Trae, Trae CN, JetBrains AI Assistant Codex, Zed, VS Code | +| CLI | Claude Code CLI, OpenAI Codex CLI, Gemini CLI, Droid CLI, Opencode CLI, Warp | +| Other Outputs | AGENTS.md-style outputs, Generic Skills, README-like outputs, `.editorconfig`, `.git/info/exclude` | -More platforms being added continuously. +More platforms are still being added. ## Architecture -- **CLI** (`@truenine/memory-sync`): core sync engine—reads config, writes target tool files, generates copy-ready prompts. -- **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. +- **SDK** (`@truenine/memory-sync-sdk` / `tnmsc` crate): the private mixed core for pipeline, prompt service, schema, bridge runtime, and core integration logic. +- **CLI Shell** (`@truenine/memory-sync-cli`): the public `tnmsc` command entry, compatibility export surface, and platform-distribution shell. +- **MCP** (`@truenine/memory-sync-mcp`): an stdio server that exposes prompt-asset management to MCP-capable hosts. +- **Libraries** (`logger`, `md-compiler`, `script-runtime`): Rust-first shared libraries. +- **GUI** (Tauri): the desktop workflow entry, consuming the `tnmsc` crate from `sdk`. ## FAQ **When AI tools finally have a unified standard, what use will this project be?** Then it will have completed its historical mission. -**There's already AGENTS.md, agentskills, and the MCP standard—why do I still need this junk?** +**There's already AGENTS.md, agentskills, and the MCP standard. Why do I still need this junk?** -Native-friendly, plus targeted conditional prompt authoring. +Because native targets still differ, and because conditional prompt authoring still has to land somewhere concrete. -`AGENTS.md` is the spec; `memory-sync` is the hauler—it writes the same memory into the native config formats of a dozen tools simultaneously, sparing you the manual copy-paste grind. +`AGENTS.md` is the format. `memory-sync` is the hauler plus assembler. It writes the same source of truth into multiple native configs and managed artifacts at once, so you do not have to do the copy-paste labor by hand. CLI, SDK, MCP, and GUI are just different entry points around that same model. -**Is there anything in your prompts you don't want to leave behind?** +**Is there anything in the prompt or generated output that I may not want to leave behind?** -Yes. That's why `memory-sync` provides a full-wipe mode: after sync, only the content you explicitly authorised remains in the target tools—everything else is erased. Prompts are fully computable, no hidden residue, no backdoor left for anyone else. +Yes. That is why `memory-sync` gives you cleanup and protection boundaries. After sync, only the managed outputs you explicitly allow should remain. Anything else should either be cleaned or protected on purpose. Prompts and derived artifacts should stay computable, auditable, and residue-free. ## Who is this for 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 +- Solid development experience and long-term exposure to dev tools +- Competence with version control (Git) +- Competence 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. -- 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. +No one is going to proactively feed you, not even a tiny free quota, not even a decent document. +- As a rat, you were never going to get good food anyway: +you keep 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. -- 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. -- 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. +Keep darting between IDEs, CLIs, browser extensions, and cloud agents, copying and pasting the same memory a hundred times. +- You keep scraping vendor API deals day after day: +today one platform discounts something, so you top up a little; tomorrow another launches a promotion, so you rush over there too. +- Once they have harvested the telemetry, user profile, and usage pattern they wanted, +they can kick you away at any moment: price hikes, quotas, bans, and no real channel for complaint. 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. +to help you carry a little less brick, paste the same prompt a few fewer times, and at least stop being completely passive around "memory". ## Who is NOT welcome @@ -96,17 +99,17 @@ 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. - 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, and 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 marginalized 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. -- You love the grind culture. +Everything must be pre-chewed and spoon-fed; you will not even touch a terminal. +- You love grind culture. 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. +This is not about whether you share everything. It is about actively stomping on people, competing maliciously, and treating other people's 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.** +**this is not a tool for optimizing capital cost. It is a small counterattack for the "rats with no choice" in a world of extreme resource inequality.** ## Created by diff --git a/SECURITY.md b/SECURITY.md index d08111a9..aeac0517 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,22 +34,23 @@ Don't rush. ## Scope -`memory-sync` is a CLI tool that **reads source files only and writes target configs only**. Its security boundary: +`memory-sync` is now a toolkit made of CLI / SDK / MCP / GUI surfaces, not just a single CLI binary. Its security boundary: -- **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 +- **Reads**: user `.src.mdx` source files, project config files, the global config file (`~/.aindex/.tnmsc.json`), and repository metadata needed for sync +- **Writes**: target-tool config directories, managed prompt artifacts such as `dist/`, generated skills / README-like outputs, and related helper configs +- **Cleans**: removes stale managed outputs and target-directory residue during sync or cleanup 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 +- Hardening of third-party dependencies, hosted platforms, or the local workstation outside this repository +- External scripts, private plugins, or unmanaged files injected by the user into the workflow ## 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 +- **Separation between source and derived state**: source files, generated artifacts, and target-tool configs must stay clearly separated, auditable, and traceable +- **Cleanup touches managed outputs only**: cleanup should only remove generated outputs or explicitly configured targets, never silently widen its delete boundary +- **No hidden telemetry**: no user data is collected or reported +- **External network behavior must be explicit**: core sync logic must not depend on hidden outbound requests; if release or docs-deploy automation talks to npm, GitHub, or Vercel, that behavior must remain visible in workflow files ## 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/Cargo.toml b/cli/Cargo.toml index fec9e6a2..f7e98554 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "tnmsc" -description = "Cross-AI-tool prompt synchronisation CLI" +name = "tnmsc-cli-shell" +description = "Cross-AI-tool prompt synchronisation CLI shell" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -8,38 +8,12 @@ license.workspace = true authors.workspace = true repository.workspace = true -[lib] -name = "tnmsc" -path = "src/lib.rs" -crate-type = ["rlib", "cdylib"] - [[bin]] name = "tnmsc" path = "src/main.rs" -[features] -default = [] -embedded-runtime = [] -napi = ["dep:napi", "dep:napi-derive"] - [dependencies] +tnmsc = { workspace = true } tnmsc-logger = { workspace = true } -tnmsc-md-compiler = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = "2.0.18" clap = { workspace = true } -dirs = { workspace = true } -sha2 = { workspace = true } -napi = { workspace = true, optional = true } -napi-derive = { workspace = true, optional = true } -reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "json", "rustls"] } -globset = "0.4.18" -walkdir = "2.5.0" - -[dev-dependencies] -proptest = "1.11.0" -tempfile = "3.27.0" - -[build-dependencies] -napi-build = { workspace = true } +serde_json = { workspace = true } diff --git a/cli/eslint.config.ts b/cli/eslint.config.ts index 9c891393..e6caf052 100644 --- a/cli/eslint.config.ts +++ b/cli/eslint.config.ts @@ -17,8 +17,8 @@ const config = await eslint10({ ignores: [ '.turbo/**', 'aindex/**', - 'npm/**/noop.cjs', - 'npm/**/noop.d.ts', + 'npm/**/noop.mjs', + 'npm/**/noop.d.mts', '*.md', '**/*.md', '*.toml', diff --git a/cli/npm/.gitignore b/cli/npm/.gitignore index d98927ce..a85693ee 100644 --- a/cli/npm/.gitignore +++ b/cli/npm/.gitignore @@ -2,5 +2,3 @@ !.gitignore !*/ !*/package.json -!*/noop.cjs -!*/noop.d.ts diff --git a/cli/npm/darwin-arm64/noop.cjs b/cli/npm/darwin-arm64/noop.cjs deleted file mode 100644 index 84c0933b..00000000 --- a/cli/npm/darwin-arm64/noop.cjs +++ /dev/null @@ -1,28 +0,0 @@ -'use strict' - -const {readdirSync} = require('node:fs') -const {join} = require('node:path') - -const EXPORT_BINDINGS = [ - ['logger', 'napi-logger.'], - ['mdCompiler', 'napi-md-compiler.'], - ['scriptRuntime', 'napi-script-runtime.'], - ['config', 'napi-memory-sync-cli.'] -] - -const nodeFiles = readdirSync(__dirname).filter(file => file.endsWith('.node')) -const bindings = {} - -for (const [exportName, prefix] of EXPORT_BINDINGS) { - const file = nodeFiles.find(candidate => candidate.startsWith(prefix)) - if (file == null) continue - - Object.defineProperty(bindings, exportName, { - enumerable: true, - get() { - return require(join(__dirname, file)) - } - }) -} - -module.exports = bindings diff --git a/cli/npm/darwin-arm64/noop.d.ts b/cli/npm/darwin-arm64/noop.d.ts deleted file mode 100644 index 667d20dc..00000000 --- a/cli/npm/darwin-arm64/noop.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const bindings: { - readonly logger?: unknown - readonly mdCompiler?: unknown - readonly scriptRuntime?: unknown - readonly config?: unknown -} - -export = bindings diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index e3cde4bc..c3eb9b4f 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.10330.118", + "version": "2026.10402.103", "os": [ "darwin" ], @@ -8,11 +8,20 @@ "arm64" ], "license": "AGPL-3.0-only", - "main": "noop.cjs", - "types": "noop.d.ts", + "exports": { + ".": { + "types": "./noop.d.mts", + "import": "./noop.mjs" + }, + "./*.node": "./*.node", + "./package.json": "./package.json" + }, "files": [ "*.node", - "noop.cjs", - "noop.d.ts" - ] + "noop.d.mts", + "noop.mjs" + ], + "scripts": { + "prepack": "pnpm exec tsx ../../../scripts/write-platform-package-shims.ts ." + } } diff --git a/cli/npm/darwin-x64/noop.cjs b/cli/npm/darwin-x64/noop.cjs deleted file mode 100644 index 84c0933b..00000000 --- a/cli/npm/darwin-x64/noop.cjs +++ /dev/null @@ -1,28 +0,0 @@ -'use strict' - -const {readdirSync} = require('node:fs') -const {join} = require('node:path') - -const EXPORT_BINDINGS = [ - ['logger', 'napi-logger.'], - ['mdCompiler', 'napi-md-compiler.'], - ['scriptRuntime', 'napi-script-runtime.'], - ['config', 'napi-memory-sync-cli.'] -] - -const nodeFiles = readdirSync(__dirname).filter(file => file.endsWith('.node')) -const bindings = {} - -for (const [exportName, prefix] of EXPORT_BINDINGS) { - const file = nodeFiles.find(candidate => candidate.startsWith(prefix)) - if (file == null) continue - - Object.defineProperty(bindings, exportName, { - enumerable: true, - get() { - return require(join(__dirname, file)) - } - }) -} - -module.exports = bindings diff --git a/cli/npm/darwin-x64/noop.d.ts b/cli/npm/darwin-x64/noop.d.ts deleted file mode 100644 index 667d20dc..00000000 --- a/cli/npm/darwin-x64/noop.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const bindings: { - readonly logger?: unknown - readonly mdCompiler?: unknown - readonly scriptRuntime?: unknown - readonly config?: unknown -} - -export = bindings diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index da2cf2a0..1947f7da 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.10330.118", + "version": "2026.10402.103", "os": [ "darwin" ], @@ -8,11 +8,20 @@ "x64" ], "license": "AGPL-3.0-only", - "main": "noop.cjs", - "types": "noop.d.ts", + "exports": { + ".": { + "types": "./noop.d.mts", + "import": "./noop.mjs" + }, + "./*.node": "./*.node", + "./package.json": "./package.json" + }, "files": [ "*.node", - "noop.cjs", - "noop.d.ts" - ] + "noop.d.mts", + "noop.mjs" + ], + "scripts": { + "prepack": "pnpm exec tsx ../../../scripts/write-platform-package-shims.ts ." + } } diff --git a/cli/npm/linux-arm64-gnu/noop.cjs b/cli/npm/linux-arm64-gnu/noop.cjs deleted file mode 100644 index 84c0933b..00000000 --- a/cli/npm/linux-arm64-gnu/noop.cjs +++ /dev/null @@ -1,28 +0,0 @@ -'use strict' - -const {readdirSync} = require('node:fs') -const {join} = require('node:path') - -const EXPORT_BINDINGS = [ - ['logger', 'napi-logger.'], - ['mdCompiler', 'napi-md-compiler.'], - ['scriptRuntime', 'napi-script-runtime.'], - ['config', 'napi-memory-sync-cli.'] -] - -const nodeFiles = readdirSync(__dirname).filter(file => file.endsWith('.node')) -const bindings = {} - -for (const [exportName, prefix] of EXPORT_BINDINGS) { - const file = nodeFiles.find(candidate => candidate.startsWith(prefix)) - if (file == null) continue - - Object.defineProperty(bindings, exportName, { - enumerable: true, - get() { - return require(join(__dirname, file)) - } - }) -} - -module.exports = bindings diff --git a/cli/npm/linux-arm64-gnu/noop.d.ts b/cli/npm/linux-arm64-gnu/noop.d.ts deleted file mode 100644 index 667d20dc..00000000 --- a/cli/npm/linux-arm64-gnu/noop.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const bindings: { - readonly logger?: unknown - readonly mdCompiler?: unknown - readonly scriptRuntime?: unknown - readonly config?: unknown -} - -export = bindings diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 646ca5fc..fa0cf32c 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.10330.118", + "version": "2026.10402.103", "os": [ "linux" ], @@ -8,11 +8,20 @@ "arm64" ], "license": "AGPL-3.0-only", - "main": "noop.cjs", - "types": "noop.d.ts", + "exports": { + ".": { + "types": "./noop.d.mts", + "import": "./noop.mjs" + }, + "./*.node": "./*.node", + "./package.json": "./package.json" + }, "files": [ "*.node", - "noop.cjs", - "noop.d.ts" - ] + "noop.d.mts", + "noop.mjs" + ], + "scripts": { + "prepack": "pnpm exec tsx ../../../scripts/write-platform-package-shims.ts ." + } } diff --git a/cli/npm/linux-x64-gnu/noop.cjs b/cli/npm/linux-x64-gnu/noop.cjs deleted file mode 100644 index 84c0933b..00000000 --- a/cli/npm/linux-x64-gnu/noop.cjs +++ /dev/null @@ -1,28 +0,0 @@ -'use strict' - -const {readdirSync} = require('node:fs') -const {join} = require('node:path') - -const EXPORT_BINDINGS = [ - ['logger', 'napi-logger.'], - ['mdCompiler', 'napi-md-compiler.'], - ['scriptRuntime', 'napi-script-runtime.'], - ['config', 'napi-memory-sync-cli.'] -] - -const nodeFiles = readdirSync(__dirname).filter(file => file.endsWith('.node')) -const bindings = {} - -for (const [exportName, prefix] of EXPORT_BINDINGS) { - const file = nodeFiles.find(candidate => candidate.startsWith(prefix)) - if (file == null) continue - - Object.defineProperty(bindings, exportName, { - enumerable: true, - get() { - return require(join(__dirname, file)) - } - }) -} - -module.exports = bindings diff --git a/cli/npm/linux-x64-gnu/noop.d.ts b/cli/npm/linux-x64-gnu/noop.d.ts deleted file mode 100644 index 667d20dc..00000000 --- a/cli/npm/linux-x64-gnu/noop.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const bindings: { - readonly logger?: unknown - readonly mdCompiler?: unknown - readonly scriptRuntime?: unknown - readonly config?: unknown -} - -export = bindings diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 1a584e6c..54554ddd 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.10330.118", + "version": "2026.10402.103", "os": [ "linux" ], @@ -8,11 +8,20 @@ "x64" ], "license": "AGPL-3.0-only", - "main": "noop.cjs", - "types": "noop.d.ts", + "exports": { + ".": { + "types": "./noop.d.mts", + "import": "./noop.mjs" + }, + "./*.node": "./*.node", + "./package.json": "./package.json" + }, "files": [ "*.node", - "noop.cjs", - "noop.d.ts" - ] + "noop.d.mts", + "noop.mjs" + ], + "scripts": { + "prepack": "pnpm exec tsx ../../../scripts/write-platform-package-shims.ts ." + } } diff --git a/cli/npm/win32-x64-msvc/noop.cjs b/cli/npm/win32-x64-msvc/noop.cjs deleted file mode 100644 index 84c0933b..00000000 --- a/cli/npm/win32-x64-msvc/noop.cjs +++ /dev/null @@ -1,28 +0,0 @@ -'use strict' - -const {readdirSync} = require('node:fs') -const {join} = require('node:path') - -const EXPORT_BINDINGS = [ - ['logger', 'napi-logger.'], - ['mdCompiler', 'napi-md-compiler.'], - ['scriptRuntime', 'napi-script-runtime.'], - ['config', 'napi-memory-sync-cli.'] -] - -const nodeFiles = readdirSync(__dirname).filter(file => file.endsWith('.node')) -const bindings = {} - -for (const [exportName, prefix] of EXPORT_BINDINGS) { - const file = nodeFiles.find(candidate => candidate.startsWith(prefix)) - if (file == null) continue - - Object.defineProperty(bindings, exportName, { - enumerable: true, - get() { - return require(join(__dirname, file)) - } - }) -} - -module.exports = bindings diff --git a/cli/npm/win32-x64-msvc/noop.d.ts b/cli/npm/win32-x64-msvc/noop.d.ts deleted file mode 100644 index 667d20dc..00000000 --- a/cli/npm/win32-x64-msvc/noop.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const bindings: { - readonly logger?: unknown - readonly mdCompiler?: unknown - readonly scriptRuntime?: unknown - readonly config?: unknown -} - -export = bindings diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index abba1832..4e18a731 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.10330.118", + "version": "2026.10402.103", "os": [ "win32" ], @@ -8,11 +8,20 @@ "x64" ], "license": "AGPL-3.0-only", - "main": "noop.cjs", - "types": "noop.d.ts", + "exports": { + ".": { + "types": "./noop.d.mts", + "import": "./noop.mjs" + }, + "./*.node": "./*.node", + "./package.json": "./package.json" + }, "files": [ "*.node", - "noop.cjs", - "noop.d.ts" - ] + "noop.d.mts", + "noop.mjs" + ], + "scripts": { + "prepack": "pnpm exec tsx ../../../scripts/write-platform-package-shims.ts ." + } } diff --git a/cli/package.json b/cli/package.json index 3f7f6a72..5e640a5d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,8 +1,8 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10330.118", - "description": "TrueNine Memory Synchronization CLI", + "version": "2026.10402.103", + "description": "TrueNine Memory Synchronization CLI shell", "author": "TrueNine", "license": "AGPL-3.0-only", "homepage": "https://github.com/TrueNine/memory-sync", @@ -48,57 +48,46 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { - "build": "run-s build:deps build:napi bundle finalize:bundle generate:schema", - "build:napi": "run-s build:native build:napi:copy", + "build": "run-s build:sdk build:shell sync:sdk-assets", + "build:sdk": "pnpm -F @truenine/memory-sync-sdk run build", "build:napi:copy": "tsx ../scripts/copy-napi.ts", - "build:native": "napi build --platform --release --output-dir dist -- --features napi", - "build:deps": "pnpm -F @truenine/logger -F @truenine/md-compiler -F @truenine/script-runtime run build", - "build:deps:ts": "pnpm -F @truenine/logger -F @truenine/md-compiler -F @truenine/script-runtime run build:ts", - "bundle": "tsx ../scripts/build-quiet.ts", - "check": "run-s build:deps:ts check:run", + "build:shell": "tsdown", + "ensure:sdk-build": "tsx scripts/ensure-sdk-build.ts", + "sync:sdk-assets": "tsx scripts/sync-sdk-dist.ts", + "check": "run-s ensure:sdk-build check:run", "check:run": "run-p lint:run typecheck:run", - "finalize:bundle": "tsx scripts/finalize-bundle.ts", - "generate:schema": "tsx scripts/generate-schema.ts", - "lint": "run-s build:deps:ts lint:run", + "lint": "run-s ensure:sdk-build lint:run", "lint:run": "eslint --cache --cache-location node_modules/.cache/.eslintcache .", "prepublishOnly": "run-s build check", - "test": "run-s build:deps test:run", - "test:native-cleanup-smoke": "tsx scripts/cleanup-native-smoke.ts", + "test": "run-s ensure:sdk-build test:run", "test:run": "vitest run", - "benchmark:cleanup": "tsx scripts/benchmark-cleanup.ts", - "lintfix": "run-s build:deps:ts lintfix:run", + "lintfix": "run-s ensure:sdk-build lintfix:run", "lintfix:run": "eslint --fix --cache --cache-location node_modules/.cache/.eslintcache .", - "typecheck": "run-s build:deps:ts typecheck:run", + "typecheck": "run-s ensure:sdk-build typecheck:run", "typecheck:run": "tsc --noEmit -p tsconfig.lib.json" }, - "dependencies": { - "json5": "catalog:", - "yaml": "catalog:", - "zod": "catalog:" - }, + "dependencies": {}, "optionalDependencies": { "@truenine/memory-sync-cli-darwin-arm64": "workspace:*", "@truenine/memory-sync-cli-darwin-x64": "workspace:*", "@truenine/memory-sync-cli-linux-arm64-gnu": "workspace:*", "@truenine/memory-sync-cli-linux-x64-gnu": "workspace:*", - "@truenine/memory-sync-cli-win32-x64-msvc": "workspace:*" + "@truenine/memory-sync-cli-win32-x64-msvc": "workspace:*", + "@truenine/script-runtime": "workspace:*", + "json5": "catalog:", + "yaml": "catalog:", + "zod": "catalog:" }, "devDependencies": { - "@clack/prompts": "catalog:", - "@truenine/logger": "workspace:*", - "@truenine/md-compiler": "workspace:*", - "@truenine/script-runtime": "workspace:*", - "@types/fs-extra": "catalog:", - "@types/picomatch": "catalog:", + "@truenine/eslint10-config": "catalog:", + "@truenine/memory-sync-sdk": "workspace:*", + "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", - "fast-glob": "catalog:", - "fs-extra": "catalog:", - "jiti": "catalog:", - "lightningcss": "catalog:", - "picocolors": "catalog:", - "picomatch": "catalog:", + "eslint": "catalog:", + "npm-run-all2": "catalog:", + "tsdown": "catalog:", "tsx": "catalog:", - "vitest": "catalog:", - "zod-to-json-schema": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/cli/scripts/ensure-sdk-build.ts b/cli/scripts/ensure-sdk-build.ts new file mode 100644 index 00000000..444d4882 --- /dev/null +++ b/cli/scripts/ensure-sdk-build.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env tsx + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const cliDir = resolve(__dirname, ".."); +const workspaceDir = resolve(cliDir, ".."); +const sdkDistDir = resolve(cliDir, "../sdk/dist"); + +const REQUIRED_SDK_OUTPUTS = ["index.mjs", "index.d.mts", "globals.mjs", "globals.d.mts", "tnmsc.schema.json"] as const; + +function hasRequiredSdkOutputs(): boolean { + return REQUIRED_SDK_OUTPUTS.every((fileName) => existsSync(resolve(sdkDistDir, fileName))); +} + +if (!hasRequiredSdkOutputs()) { + const result = spawnSync("pnpm", ["-F", "@truenine/memory-sync-sdk", "run", "build"], { + cwd: workspaceDir, + stdio: "inherit", + }); + + process.exit(result.status ?? 1); +} diff --git a/cli/scripts/sync-sdk-dist.ts b/cli/scripts/sync-sdk-dist.ts new file mode 100644 index 00000000..be4980a8 --- /dev/null +++ b/cli/scripts/sync-sdk-dist.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env tsx + +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const cliDir = resolve(__dirname, ".."); +const sdkDistDir = resolve(cliDir, "../sdk/dist"); +const cliDistDir = resolve(cliDir, "dist"); + +const EXACT_FILES = new Set(["tnmsc.schema.json"]); + +function shouldCopy(fileName: string): boolean { + return EXACT_FILES.has(fileName) || /^jiti-.*\.mjs$/u.test(fileName); +} + +if (!existsSync(sdkDistDir)) { + throw new Error(`sdk dist directory is missing: ${sdkDistDir}`); +} + +mkdirSync(cliDistDir, { recursive: true }); + +for (const fileName of readdirSync(cliDistDir)) { + if (!shouldCopy(fileName)) continue; + rmSync(join(cliDistDir, fileName), { force: true, recursive: true }); +} + +for (const fileName of readdirSync(sdkDistDir)) { + if (!shouldCopy(fileName)) continue; + cpSync(join(sdkDistDir, fileName), join(cliDistDir, fileName), { recursive: true }); +} diff --git a/cli/src/PluginPipeline.test.ts b/cli/src/PluginPipeline.test.ts deleted file mode 100644 index 27d12a4f..00000000 --- a/cli/src/PluginPipeline.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type {PipelineConfig} from './config' -import type {OutputPlugin} from './plugins/plugin-core' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {describe, expect, it} from 'vitest' -import {mergeConfig} from './config' -import {PluginPipeline} from './PluginPipeline' -import {createLogger, FilePathKind, PluginKind} from './plugins/plugin-core' - -describe('plugin pipeline output contexts', () => { - it('passes user config options into write contexts', async () => { - const tempDir = path.resolve('tmp/plugin-pipeline-frontmatter') - fs.rmSync(tempDir, {recursive: true, force: true}) - fs.mkdirSync(tempDir, {recursive: true}) - - const outputPath = path.join(tempDir, 'frontmatter.txt') - let seenBlankLineAfter: boolean | undefined - - const plugin: OutputPlugin = { - type: PluginKind.Output, - name: 'CaptureOutputPlugin', - log: createLogger('CaptureOutputPlugin', 'error'), - declarativeOutput: true, - outputCapabilities: {}, - async declareOutputFiles(ctx) { - seenBlankLineAfter = ctx.pluginOptions?.frontMatter?.blankLineAfter - return [{path: outputPath, source: 'capture'}] - }, - async convertContent(_declaration, ctx) { - return String(ctx.pluginOptions?.frontMatter?.blankLineAfter) - } - } - - const config: PipelineConfig = { - context: { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: tempDir, - getDirectoryName: () => path.basename(tempDir) - }, - projects: [] - } - }, - outputPlugins: [plugin], - userConfigOptions: mergeConfig({ - workspaceDir: tempDir, - frontMatter: { - blankLineAfter: false - } - }) - } - - const result = await new PluginPipeline('node', 'tnmsc').run(config) - - expect(result.success).toBe(true) - expect(seenBlankLineAfter).toBe(false) - expect(fs.readFileSync(outputPath, 'utf8')).toBe('false') - }) -}) diff --git a/cli/src/PluginPipeline.ts b/cli/src/PluginPipeline.ts index 652952ba..b5e15090 100644 --- a/cli/src/PluginPipeline.ts +++ b/cli/src/PluginPipeline.ts @@ -1,20 +1,19 @@ -import type {ILogger, OutputCleanContext, OutputCollectedContext, OutputPlugin, OutputRuntimeTargets, OutputWriteContext, PluginOptions} from './plugins/plugin-core' +import type { + ILogger, + OutputCleanContext, + OutputCollectedContext, + OutputPlugin, + OutputRuntimeTargets, + OutputWriteContext, + PipelineConfig, + PluginOptions +} from '@truenine/memory-sync-sdk' import type {Command, CommandContext, CommandResult} from '@/commands/Command' -import type {PipelineConfig} from '@/config' import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' +import {createLogger, discoverOutputRuntimeTargets, setGlobalLogLevel} from '@truenine/memory-sync-sdk' import {JsonOutputCommand} from '@/commands/JsonOutputCommand' import {extractUserArgs, parseArgs, resolveCommand} from '@/pipeline/CliArgumentParser' -import {discoverOutputRuntimeTargets} from '@/pipeline/OutputRuntimeTargets' -import {createLogger, setGlobalLogLevel} from './plugins/plugin-core' -/** - * Plugin Pipeline - Orchestrates plugin execution - * - * This class has been refactored to use modular components: - * - CliArgumentParser: CLI argument parsing (moved to @/pipeline) - * - DependencyResolver: dependency ordering (moved to @/pipeline) - * - ContextMerger: Context merging (moved to @/pipeline) - */ export class PluginPipeline { private readonly logger: ILogger readonly args: ParsedCliArgs @@ -23,12 +22,9 @@ export class PluginPipeline { constructor(...cmdArgs: (string | undefined)[]) { const filtered = cmdArgs.filter((arg): arg is string => arg != null) - const userArgs = extractUserArgs(filtered) - this.args = parseArgs(userArgs) - - const resolvedLogLevel = this.args.logLevel // Resolve log level from parsed args and set globally - if (resolvedLogLevel != null) setGlobalLogLevel(resolvedLogLevel) - this.logger = createLogger('PluginPipeline', resolvedLogLevel) + this.args = parseArgs(extractUserArgs(filtered)) + if (this.args.logLevel != null) setGlobalLogLevel(this.args.logLevel) + this.logger = createLogger('PluginPipeline', this.args.logLevel) this.logger.debug('initialized', {args: this.args}) } @@ -40,18 +36,13 @@ export class PluginPipeline { async run(config: PipelineConfig): Promise { const {context, outputPlugins, userConfigOptions} = config this.registerOutputPlugins([...outputPlugins]) - let command: Command = resolveCommand(this.args) - if (this.args.jsonFlag) { - setGlobalLogLevel('silent') // Suppress all console logging in JSON mode - - const selfJsonCommands = new Set(['config-show', 'plugins']) // only need log suppression, not JsonOutputCommand wrapping // Commands that handle their own JSON output (config --show, plugins) - if (!selfJsonCommands.has(command.name)) command = new JsonOutputCommand(command) - } + if (!this.args.jsonFlag) return command.execute(this.createCommandContext(context, userConfigOptions)) - const commandCtx = this.createCommandContext(context, userConfigOptions) - return command.execute(commandCtx) + setGlobalLogLevel('silent') + if (!new Set(['config-show', 'plugins']).has(command.name)) command = new JsonOutputCommand(command) + return command.execute(this.createCommandContext(context, userConfigOptions)) } private createCommandContext(ctx: OutputCollectedContext, userConfigOptions: Required): CommandContext { @@ -60,16 +51,12 @@ export class PluginPipeline { outputPlugins: this.outputPlugins, collectedOutputContext: ctx, userConfigOptions, - createCleanContext: (dryRun: boolean) => this.createCleanContext(ctx, userConfigOptions, dryRun), - createWriteContext: (dryRun: boolean) => this.createWriteContext(ctx, userConfigOptions, dryRun) + createCleanContext: dryRun => this.createCleanContext(ctx, userConfigOptions, dryRun), + createWriteContext: dryRun => this.createWriteContext(ctx, userConfigOptions, dryRun) } } - private createCleanContext( - ctx: OutputCollectedContext, - userConfigOptions: Required, - dryRun: boolean - ): OutputCleanContext { + private createCleanContext(ctx: OutputCollectedContext, userConfigOptions: Required, dryRun: boolean): OutputCleanContext { return { logger: this.logger, collectedOutputContext: ctx, @@ -79,18 +66,14 @@ export class PluginPipeline { } } - private createWriteContext( - ctx: OutputCollectedContext, - userConfigOptions: Required, - dryRun: boolean - ): OutputWriteContext { + private createWriteContext(ctx: OutputCollectedContext, userConfigOptions: Required, dryRun: boolean): OutputWriteContext { return { logger: this.logger, collectedOutputContext: ctx, pluginOptions: userConfigOptions, runtimeTargets: this.getRuntimeTargets(), dryRun, - registeredPluginNames: this.outputPlugins.map(p => p.name) + registeredPluginNames: this.outputPlugins.map(plugin => plugin.name) } } diff --git a/cli/src/cli-runtime.test.ts b/cli/src/cli-runtime.test.ts index ab877f20..f7d2f320 100644 --- a/cli/src/cli-runtime.test.ts +++ b/cli/src/cli-runtime.test.ts @@ -1,10 +1,6 @@ import {afterEach, describe, expect, it, vi} from 'vitest' -const { - createDefaultPluginConfigMock, - pipelineRunMock, - pluginPipelineCtorMock -} = vi.hoisted(() => ({ +const {createDefaultPluginConfigMock, pipelineRunMock, pluginPipelineCtorMock} = vi.hoisted(() => ({ createDefaultPluginConfigMock: vi.fn(), pipelineRunMock: vi.fn(), pluginPipelineCtorMock: vi.fn() @@ -17,9 +13,7 @@ vi.mock('./plugin.config', () => ({ vi.mock('./PluginPipeline', () => ({ PluginPipeline: function MockPluginPipeline(...args: unknown[]) { pluginPipelineCtorMock(...args) - return { - run: pipelineRunMock - } + return {run: pipelineRunMock} } })) @@ -31,9 +25,7 @@ afterEach(() => { describe('cli runtime lightweight commands', () => { it('does not load plugin config for --version', async () => { const {runCli} = await import('./cli-runtime') - const exitCode = await runCli(['node', 'tnmsc', '--version']) - expect(exitCode).toBe(0) expect(createDefaultPluginConfigMock).not.toHaveBeenCalled() expect(pluginPipelineCtorMock).not.toHaveBeenCalled() @@ -43,24 +35,16 @@ describe('cli runtime lightweight commands', () => { it('emits JSON for --version --json without loading plugin config', async () => { const {runCli} = await import('./cli-runtime') const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - try { const exitCode = await runCli(['node', 'tnmsc', '--version', '--json']) - expect(exitCode).toBe(0) expect(createDefaultPluginConfigMock).not.toHaveBeenCalled() expect(pluginPipelineCtorMock).not.toHaveBeenCalled() expect(pipelineRunMock).not.toHaveBeenCalled() - - const payload = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) as { - readonly success: boolean - readonly message?: string - } - + const payload = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) as {readonly success: boolean, readonly message?: string} expect(payload.success).toBe(true) expect(payload.message).toBe('Version displayed') - } - finally { + } finally { writeSpy.mockRestore() } }) diff --git a/cli/src/cli-runtime.ts b/cli/src/cli-runtime.ts index 213b8bdf..15b9da60 100644 --- a/cli/src/cli-runtime.ts +++ b/cli/src/cli-runtime.ts @@ -1,30 +1,42 @@ import type {Command, CommandContext, CommandResult} from '@/commands/Command' import * as path from 'node:path' import process from 'node:process' +import { + buildUnhandledExceptionDiagnostic, + createLogger, + drainBufferedDiagnostics, + FilePathKind, + flushOutput, + mergeConfig, + setGlobalLogLevel +} from '@truenine/memory-sync-sdk' import {JsonOutputCommand, toJsonCommandResult} from '@/commands/JsonOutputCommand' -import {buildUnhandledExceptionDiagnostic} from '@/diagnostics' +import {extractUserArgs, parseArgs, resolveCommand} from '@/pipeline/CliArgumentParser' import {PluginPipeline} from '@/PluginPipeline' -import {mergeConfig} from './config' -import {extractUserArgs, parseArgs, resolveCommand} from './pipeline/CliArgumentParser' import {createDefaultPluginConfig} from './plugin.config' -import {createLogger, drainBufferedDiagnostics, FilePathKind, setGlobalLogLevel} from './plugins/plugin-core' const LIGHTWEIGHT_COMMAND_NAMES = new Set(['help', 'version', 'unknown']) export function isJsonMode(argv: readonly string[]): boolean { - return argv.some(arg => arg === '--json' || arg === '-j' || /^-[^-]*j/.test(arg)) + return argv.some(arg => arg === '--json' || arg === '-j' || /^-[^-]*j/u.test(arg)) } function writeJsonFailure(error: unknown): void { - const errorMessage = error instanceof Error ? error.message : String(error) const logger = createLogger('main', 'silent') logger.error(buildUnhandledExceptionDiagnostic('main', error)) - process.stdout.write(`${JSON.stringify(toJsonCommandResult({ - success: false, - filesAffected: 0, - dirsAffected: 0, - message: errorMessage - }, drainBufferedDiagnostics()))}\n`) + process.stdout.write( + `${JSON.stringify( + toJsonCommandResult( + { + success: false, + filesAffected: 0, + dirsAffected: 0, + message: error instanceof Error ? error.message : String(error) + }, + drainBufferedDiagnostics() + ) + )}\n` + ) } function createUnavailableContext(kind: 'cleanup' | 'write'): never { @@ -33,11 +45,7 @@ function createUnavailableContext(kind: 'cleanup' | 'write'): never { function createLightweightCommandContext(logLevel: ReturnType['logLevel']): CommandContext { const workspaceDir = process.cwd() - const userConfigOptions = mergeConfig({ - workspaceDir, - ...logLevel != null ? {logLevel} : {} - }) - + const userConfigOptions = mergeConfig({workspaceDir, ...logLevel != null ? {logLevel} : {}}) return { logger: createLogger('PluginPipeline', logLevel), outputPlugins: [], @@ -57,27 +65,16 @@ function createLightweightCommandContext(logLevel: ReturnType[ } } -function resolveLightweightCommand(argv: readonly string[]): { - readonly command: Command - readonly context: CommandContext -} | undefined { - const filteredArgs = argv.filter((arg): arg is string => arg != null) - const parsedArgs = parseArgs(extractUserArgs(filteredArgs)) +function resolveLightweightCommand(argv: readonly string[]): {readonly command: Command, readonly context: CommandContext} | undefined { + const parsedArgs = parseArgs(extractUserArgs(argv.filter((arg): arg is string => arg != null))) let command: Command = resolveCommand(parsedArgs) - if (!LIGHTWEIGHT_COMMAND_NAMES.has(command.name)) return void 0 - if (parsedArgs.logLevel != null) setGlobalLogLevel(parsedArgs.logLevel) + if (!parsedArgs.jsonFlag) return {command, context: createLightweightCommandContext(parsedArgs.logLevel)} - if (parsedArgs.jsonFlag) { - setGlobalLogLevel('silent') - command = new JsonOutputCommand(command) - } - - return { - command, - context: createLightweightCommandContext(parsedArgs.logLevel) - } + setGlobalLogLevel('silent') + command = new JsonOutputCommand(command) + return {command, context: createLightweightCommandContext(parsedArgs.logLevel)} } export async function runCli(argv: readonly string[] = process.argv): Promise { @@ -85,22 +82,24 @@ export async function runCli(argv: readonly string[] = process.argv): Promise ResolvedCommand { } else { let pairs = parse_key_value_pairs(args); if pairs.is_empty() { - // No key=value pairs and no --show: default to execute ResolvedCommand::Execute } else { ResolvedCommand::Config(pairs) @@ -227,110 +226,36 @@ pub fn resolve_command(cli: &Cli) -> ResolvedCommand { #[cfg(test)] mod tests { use super::*; - - fn parse(args: &[&str]) -> Cli { - Cli::try_parse_from(args).unwrap() - } + use clap::Parser; #[test] - fn test_no_args_defaults_to_execute() { - let cli = parse(&["tnmsc"]); + fn resolve_command_defaults_to_execute() { + let cli = Cli::parse_from(["tnmsc"]); assert_eq!(resolve_command(&cli), ResolvedCommand::Execute); } #[test] - fn test_help_subcommand() { - let cli = parse(&["tnmsc", "help"]); - assert_eq!(resolve_command(&cli), ResolvedCommand::Help); - } - - #[test] - fn test_version_subcommand() { - let cli = parse(&["tnmsc", "version"]); - assert_eq!(resolve_command(&cli), ResolvedCommand::Version); - } - - #[test] - fn test_dry_run_subcommand() { - let cli = parse(&["tnmsc", "dry-run"]); - assert_eq!(resolve_command(&cli), ResolvedCommand::DryRun); - } - - #[test] - fn test_clean_subcommand() { - let cli = parse(&["tnmsc", "clean"]); - assert_eq!(resolve_command(&cli), ResolvedCommand::Clean); - } - - #[test] - fn test_clean_dry_run() { - let cli = parse(&["tnmsc", "clean", "--dry-run"]); + fn resolve_command_parses_clean_dry_run() { + let cli = Cli::parse_from(["tnmsc", "clean", "--dry-run"]); assert_eq!(resolve_command(&cli), ResolvedCommand::DryRunClean); } #[test] - fn test_clean_short_dry_run() { - let cli = parse(&["tnmsc", "clean", "-n"]); - assert_eq!(resolve_command(&cli), ResolvedCommand::DryRunClean); - } - - #[test] - fn test_config_show() { - let cli = parse(&["tnmsc", "config", "--show"]); - assert_eq!(resolve_command(&cli), ResolvedCommand::ConfigShow); - } - - #[test] - fn test_config_set() { - let cli = parse(&["tnmsc", "config", "workspaceDir=~/my-project"]); - assert_eq!( - resolve_command(&cli), - ResolvedCommand::Config(vec![("workspaceDir".into(), "~/my-project".into())]) - ); - } + fn config_key_value_parsing_combines_flag_and_positional_pairs() { + let cli = Cli::parse_from([ + "tnmsc", + "config", + "--set", + "workspaceDir=/tmp/workspace", + "logLevel=debug", + ]); - #[test] - fn test_config_set_flag() { - let cli = parse(&["tnmsc", "config", "--set", "logLevel=debug"]); assert_eq!( resolve_command(&cli), - ResolvedCommand::Config(vec![("logLevel".into(), "debug".into())]) + ResolvedCommand::Config(vec![ + ("workspaceDir".to_string(), "/tmp/workspace".to_string()), + ("logLevel".to_string(), "debug".to_string()), + ]) ); } - - #[test] - fn test_plugins_subcommand() { - let cli = parse(&["tnmsc", "plugins"]); - assert_eq!(resolve_command(&cli), ResolvedCommand::Plugins); - } - - #[test] - fn test_json_flag() { - let cli = parse(&["tnmsc", "--json"]); - assert!(cli.json); - } - - #[test] - fn test_json_short_flag() { - let cli = parse(&["tnmsc", "-j"]); - assert!(cli.json); - } - - #[test] - fn test_log_level_trace() { - let cli = parse(&["tnmsc", "--trace"]); - assert_eq!(resolve_log_level(&cli), Some(ResolvedLogLevel::Trace)); - } - - #[test] - fn test_log_level_multiple_most_verbose_wins() { - let cli = parse(&["tnmsc", "--warn", "--debug"]); - assert_eq!(resolve_log_level(&cli), Some(ResolvedLogLevel::Debug)); - } - - #[test] - fn test_no_log_level() { - let cli = parse(&["tnmsc"]); - assert_eq!(resolve_log_level(&cli), None); - } } diff --git a/cli/src/commands/CleanCommand.ts b/cli/src/commands/CleanCommand.ts index bb8be0a8..ec99c8bd 100644 --- a/cli/src/commands/CleanCommand.ts +++ b/cli/src/commands/CleanCommand.ts @@ -1,34 +1,17 @@ import type {Command, CommandContext, CommandResult} from './Command' -import {performCleanup} from './CleanupUtils' +import {performCleanup} from '@truenine/memory-sync-sdk' -/** - * Clean command - deletes registered output files and directories - */ export class CleanCommand implements Command { readonly name = 'clean' async execute(ctx: CommandContext): Promise { const {logger, outputPlugins, createCleanContext} = ctx logger.info('running clean pipeline', {command: 'clean'}) - - const cleanCtx = createCleanContext(false) - const result = await performCleanup(outputPlugins, cleanCtx, logger) - + const result = await performCleanup(outputPlugins, createCleanContext(false), logger) if (result.violations.length > 0 || result.conflicts.length > 0) { - return { - success: false, - filesAffected: 0, - dirsAffected: 0, - ...result.message != null ? {message: result.message} : {} - } + return {success: false, filesAffected: 0, dirsAffected: 0, ...result.message != null ? {message: result.message} : {}} } - logger.info('clean complete', {deletedFiles: result.deletedFiles, deletedDirs: result.deletedDirs}) - - return { - success: true, - filesAffected: result.deletedFiles, - dirsAffected: result.deletedDirs - } + return {success: true, filesAffected: result.deletedFiles, dirsAffected: result.deletedDirs} } } diff --git a/cli/src/commands/CleanupUtils.adapter.test.ts b/cli/src/commands/CleanupUtils.adapter.test.ts deleted file mode 100644 index 069ea3ab..00000000 --- a/cli/src/commands/CleanupUtils.adapter.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type {ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputPlugin} from '../plugins/plugin-core' -import * as fs from 'node:fs' -import * as path from 'node:path' -import glob from 'fast-glob' -import {describe, expect, it, vi} from 'vitest' -import {FilePathKind, PluginKind} from '../plugins/plugin-core' - -const nativeBindingMocks = vi.hoisted(() => ({ - planCleanup: vi.fn<(snapshotJson: string) => string>(), - performCleanup: vi.fn<(snapshotJson: string) => string>() -})) - -vi.mock('../core/native-binding', () => ({ - getNativeBinding: () => ({ - ...globalThis.__TNMSC_TEST_NATIVE_BINDING__, - planCleanup: nativeBindingMocks.planCleanup, - performCleanup: nativeBindingMocks.performCleanup - }) -})) - -const cleanupModulePromise = import('./CleanupUtils') - -function createMockLogger(): ILogger { - return { - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - fatal: () => {} - } as ILogger -} - -function createCleanContext(workspaceDir: string): OutputCleanContext { - return { - logger: createMockLogger(), - fs, - path, - glob, - collectedOutputContext: { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [ - { - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: 'project-a', - basePath: workspaceDir, - getDirectoryName: () => 'project-a', - getAbsolutePath: () => path.join(workspaceDir, 'project-a') - } - } - ] - }, - aindexDir: path.join(workspaceDir, 'aindex') - } - } as OutputCleanContext -} - -function createMockOutputPlugin(): OutputPlugin { - return { - type: PluginKind.Output, - name: 'MockOutputPlugin', - log: createMockLogger(), - declarativeOutput: true, - outputCapabilities: {}, - async declareOutputFiles() { - return [{path: path.join('/tmp', 'project-a', 'AGENTS.md'), source: {}}] - }, - async declareCleanupPaths(): Promise { - return { - delete: [{kind: 'glob', path: path.join('/tmp', '.codex', 'skills', '*'), excludeBasenames: ['.system']}] - } - }, - async convertContent() { - return 'test' - } - } -} - -describe('cleanupUtils native adapter', () => { - it('uses the native cleanup bridge when it is available', async () => { - nativeBindingMocks.planCleanup.mockReset() - nativeBindingMocks.performCleanup.mockReset() - - nativeBindingMocks.planCleanup.mockReturnValue( - JSON.stringify({ - filesToDelete: ['/tmp/project-a/AGENTS.md'], - dirsToDelete: ['/tmp/.codex/skills/legacy'], - emptyDirsToDelete: ['/tmp/.codex/skills'], - violations: [], - conflicts: [], - excludedScanGlobs: ['**/.git/**'] - }) - ) - nativeBindingMocks.performCleanup.mockReturnValue( - JSON.stringify({ - deletedFiles: 1, - deletedDirs: 2, - errors: [], - violations: [], - conflicts: [], - filesToDelete: ['/tmp/project-a/AGENTS.md'], - dirsToDelete: ['/tmp/.codex/skills/legacy'], - emptyDirsToDelete: ['/tmp/.codex/skills'], - excludedScanGlobs: ['**/.git/**'] - }) - ) - - const {collectDeletionTargets, hasNativeCleanupBinding, performCleanup} = await cleanupModulePromise - const workspaceDir = path.resolve('tmp-native-cleanup-adapter') - const cleanCtx = createCleanContext(workspaceDir) - const plugin = createMockOutputPlugin() - - expect(hasNativeCleanupBinding()).toBe(true) - - const plan = await collectDeletionTargets([plugin], cleanCtx) - expect(plan).toEqual({ - filesToDelete: ['/tmp/project-a/AGENTS.md'], - dirsToDelete: ['/tmp/.codex/skills/legacy'], - emptyDirsToDelete: ['/tmp/.codex/skills'], - violations: [], - conflicts: [], - excludedScanGlobs: ['**/.git/**'] - }) - expect(nativeBindingMocks.planCleanup).toHaveBeenCalledOnce() - - const planSnapshot = JSON.parse(String(nativeBindingMocks.planCleanup.mock.calls[0]?.[0])) as { - readonly pluginSnapshots: readonly {pluginName: string, outputs: readonly string[], cleanup: {delete?: readonly {kind: string}[]}}[] - } - expect(planSnapshot.pluginSnapshots).toEqual([ - expect.objectContaining({ - pluginName: 'MockOutputPlugin', - outputs: ['/tmp/project-a/AGENTS.md'], - cleanup: expect.objectContaining({ - delete: [expect.objectContaining({kind: 'glob'})] - }) - }) - ]) - - const result = await performCleanup([plugin], cleanCtx, createMockLogger()) - expect(result).toEqual({ - deletedFiles: 1, - deletedDirs: 3, - errors: [], - violations: [], - conflicts: [] - }) - expect(nativeBindingMocks.performCleanup).toHaveBeenCalledOnce() - }) -}) diff --git a/cli/src/commands/CleanupUtils.test.ts b/cli/src/commands/CleanupUtils.test.ts deleted file mode 100644 index 9d4f9f62..00000000 --- a/cli/src/commands/CleanupUtils.test.ts +++ /dev/null @@ -1,782 +0,0 @@ -import type {ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputPlugin} from '../plugins/plugin-core' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import glob from 'fast-glob' -import {describe, expect, it} from 'vitest' -import {mergeConfig} from '../config' -import {FilePathKind, IDEKind, PluginKind} from '../plugins/plugin-core' -import {collectDeletionTargets, performCleanup} from './CleanupUtils' - -function createMockLogger(): ILogger { - return { - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - fatal: () => {} - } as ILogger -} - -function createRecordingLogger(): ILogger & {debugMessages: unknown[]} { - const debugMessages: unknown[] = [] - - return { - debugMessages, - trace: () => {}, - debug: message => { - debugMessages.push(message) - }, - info: () => {}, - warn: () => {}, - error: () => {}, - fatal: () => {} - } as ILogger & {debugMessages: unknown[]} -} - -function createCleanContext( - overrides?: Partial, - pluginOptionsOverrides?: Parameters[0] -): OutputCleanContext { - const workspaceDir = path.resolve('tmp-cleanup-utils-workspace') - return { - logger: createMockLogger(), - fs, - path, - glob, - dryRun: true, - pluginOptions: mergeConfig(pluginOptionsOverrides ?? {}), - collectedOutputContext: { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] - }, - ...overrides - } - } as OutputCleanContext -} - -function createMockOutputPlugin(name: string, outputs: readonly string[], cleanup?: OutputCleanupDeclarations): OutputPlugin { - return { - type: PluginKind.Output, - name, - log: createMockLogger(), - declarativeOutput: true, - outputCapabilities: {}, - async declareOutputFiles() { - return outputs.map(output => ({path: output, source: {}})) - }, - async declareCleanupPaths() { - return cleanup ?? {} - }, - async convertContent() { - return '' - } - } -} - -describe('collectDeletionTargets', () => { - it('throws when an output path matches a protected input source file', async () => { - const editorSource = path.resolve('tmp-aindex/public/.editorconfig') - const ignoreSource = path.resolve('tmp-aindex/public/.cursorignore') - - const ctx = createCleanContext({ - editorConfigFiles: [ - { - type: IDEKind.EditorConfig, - content: 'root = true', - length: 11, - filePathKind: FilePathKind.Absolute, - dir: { - pathKind: FilePathKind.Absolute, - path: editorSource, - getDirectoryName: () => '.editorconfig' - } - } - ], - aiAgentIgnoreConfigFiles: [ - { - fileName: '.cursorignore', - content: 'node_modules', - sourcePath: ignoreSource - } - ] - }) - - const plugin = createMockOutputPlugin('MockOutputPlugin', [editorSource, ignoreSource]) - - await expect(collectDeletionTargets([plugin], ctx)).rejects.toThrow('Cleanup protection conflict') - }) - - it('keeps non-overlapping output paths for cleanup', async () => { - const outputA = path.resolve('tmp-out/a.md') - const outputB = path.resolve('tmp-out/b.md') - const ctx = createCleanContext() - const plugin = createMockOutputPlugin('MockOutputPlugin', [outputA, outputB]) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(new Set(result.filesToDelete)).toEqual(new Set([outputA, outputB])) - expect(result.violations).toEqual([]) - }) - - it('throws when an output path matches a known aindex protected config file', async () => { - const aindexDir = path.resolve('tmp-aindex') - const editorConfigOutput = path.resolve(aindexDir, 'public', '.editorconfig') - const ctx = createCleanContext({aindexDir}) - const plugin = createMockOutputPlugin('MockOutputPlugin', [editorConfigOutput]) - - await expect(collectDeletionTargets([plugin], ctx)).rejects.toThrow('Cleanup protection conflict') - }) - - it('compacts nested delete targets to reduce IO', async () => { - const claudeBaseDir = path.resolve('tmp-out/.claude') - const ruleDir = path.join(claudeBaseDir, 'rules') - const ruleFile = path.join(ruleDir, 'a.md') - const ctx = createCleanContext() - const plugin = createMockOutputPlugin('MockOutputPlugin', [ruleFile], { - delete: [ - {kind: 'directory', path: claudeBaseDir}, - {kind: 'directory', path: ruleDir}, - {kind: 'file', path: ruleFile} - ] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.dirsToDelete).toEqual([claudeBaseDir]) - expect(result.filesToDelete).toEqual([]) - }) - - it('skips parent deletion when a protected child path exists', async () => { - const codexBaseDir = path.resolve('tmp-out/.codex') - const promptsDir = path.join(codexBaseDir, 'prompts') - const protectedSystemDir = path.join(codexBaseDir, 'skills', '.system') - const ctx = createCleanContext() - const plugin = createMockOutputPlugin('MockOutputPlugin', [], { - delete: [ - {kind: 'directory', path: codexBaseDir}, - {kind: 'directory', path: promptsDir} - ], - protect: [{kind: 'directory', path: protectedSystemDir}] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.dirsToDelete).toEqual([promptsDir]) - expect(result.violations.map(violation => violation.targetPath)).toEqual([codexBaseDir]) - }) - - it('blocks deleting dangerous roots and returns the most specific matching rule', async () => { - const homeDir = os.homedir() - const ctx = createCleanContext() - const plugin = createMockOutputPlugin('MockOutputPlugin', [], { - delete: [{kind: 'directory', path: homeDir}] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.dirsToDelete).toEqual([]) - expect(result.filesToDelete).toEqual([]) - expect(result.violations).toEqual([ - expect.objectContaining({ - targetPath: path.resolve(homeDir), - protectedPath: path.resolve('tmp-cleanup-utils-workspace', 'knowladge'), - protectionMode: 'direct' - }) - ]) - }) - - it('throws when an output path matches a built-in protected path before directory guards run', async () => { - const workspaceDir = path.resolve('tmp-workspace-root') - const projectRoot = path.join(workspaceDir, 'project-a') - const aindexDir = path.join(workspaceDir, 'aindex') - const globalAindexDir = path.join(os.homedir(), '.aindex') - const globalConfigPath = path.join(globalAindexDir, '.tnmsc.json') - const ctx = createCleanContext({ - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [ - { - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: 'project-a', - basePath: workspaceDir, - getDirectoryName: () => 'project-a', - getAbsolutePath: () => projectRoot - } - } - ] - }, - aindexDir - }) - const plugin = createMockOutputPlugin('MockOutputPlugin', [globalConfigPath], { - delete: [ - {kind: 'directory', path: globalAindexDir}, - {kind: 'directory', path: workspaceDir}, - {kind: 'directory', path: projectRoot}, - {kind: 'directory', path: aindexDir} - ] - }) - - await expect(collectDeletionTargets([plugin], ctx)).rejects.toThrow( - `Cleanup protection conflict: 1 output path(s) are also protected: ${path.resolve(globalConfigPath)}` - ) - }) - - it('allows deleting non-mdx files under dist while blocking reserved dist mdx files', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-cleanup-dist-mdx-')) - const workspaceDir = path.join(tempDir, 'workspace') - const distCommandDir = path.join(workspaceDir, 'aindex', 'dist', 'commands') - const projectChildFile = path.join(workspaceDir, 'project-a', 'AGENTS.md') - const protectedDistMdxFile = path.join(distCommandDir, 'demo.mdx') - const safeDistMarkdownFile = path.join(distCommandDir, 'README.md') - const globalChildDir = path.join(os.homedir(), '.aindex', '.codex', 'prompts') - const aindexSourceDir = path.join(workspaceDir, 'aindex', 'commands') - - fs.mkdirSync(path.dirname(projectChildFile), {recursive: true}) - fs.mkdirSync(distCommandDir, {recursive: true}) - fs.mkdirSync(aindexSourceDir, {recursive: true}) - fs.writeFileSync(projectChildFile, '# agent', 'utf8') - fs.writeFileSync(protectedDistMdxFile, '# compiled', 'utf8') - fs.writeFileSync(safeDistMarkdownFile, '# doc', 'utf8') - - try { - const ctx = createCleanContext({ - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [ - { - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: 'project-a', - basePath: workspaceDir, - getDirectoryName: () => 'project-a', - getAbsolutePath: () => path.join(workspaceDir, 'project-a') - } - } - ] - }, - aindexDir: path.join(workspaceDir, 'aindex') - }) - const plugin = createMockOutputPlugin('MockOutputPlugin', [projectChildFile, safeDistMarkdownFile], { - delete: [ - {kind: 'file', path: protectedDistMdxFile}, - {kind: 'directory', path: globalChildDir}, - {kind: 'directory', path: aindexSourceDir} - ] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(new Set(result.filesToDelete)).toEqual(new Set([path.resolve(projectChildFile), path.resolve(safeDistMarkdownFile)])) - const allDirsToDelete = [...result.dirsToDelete, ...result.emptyDirsToDelete] - expect(new Set(allDirsToDelete)).toEqual(new Set([path.resolve(globalChildDir), path.resolve(aindexSourceDir), path.resolve(workspaceDir, 'project-a')])) - expect(result.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - targetPath: path.resolve(protectedDistMdxFile), - protectionMode: 'direct', - protectedPath: path.resolve(protectedDistMdxFile) - }), - expect.objectContaining({targetPath: path.resolve(aindexSourceDir)}) - ]) - ) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) - - it('blocks deleting a dist directory when protected mdx descendants exist', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-cleanup-dist-dir-')) - const workspaceDir = path.join(tempDir, 'workspace') - const distCommandDir = path.join(workspaceDir, 'aindex', 'dist', 'commands') - const protectedDistMdxFile = path.join(distCommandDir, 'demo.mdx') - - fs.mkdirSync(distCommandDir, {recursive: true}) - fs.writeFileSync(protectedDistMdxFile, '# compiled', 'utf8') - - try { - const ctx = createCleanContext({ - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] - }, - aindexDir: path.join(workspaceDir, 'aindex') - }) - const plugin = createMockOutputPlugin('MockOutputPlugin', [], { - delete: [{kind: 'directory', path: distCommandDir}] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.dirsToDelete).toEqual([]) - expect(result.filesToDelete).toEqual([]) - expect(result.violations).toEqual([ - expect.objectContaining({ - targetPath: path.resolve(distCommandDir), - protectionMode: 'direct', - protectedPath: path.resolve(protectedDistMdxFile) - }) - ]) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) - - it('allows deleting non-mdx files under app while blocking reserved app mdx files', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-cleanup-app-mdx-')) - const workspaceDir = path.join(tempDir, 'workspace') - const appDir = path.join(workspaceDir, 'aindex', 'app') - const protectedAppMdxFile = path.join(appDir, 'guide.mdx') - const safeAppMarkdownFile = path.join(appDir, 'README.md') - - fs.mkdirSync(appDir, {recursive: true}) - fs.writeFileSync(protectedAppMdxFile, '# app guide', 'utf8') - fs.writeFileSync(safeAppMarkdownFile, '# readme', 'utf8') - - try { - const ctx = createCleanContext({ - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] - }, - aindexDir: path.join(workspaceDir, 'aindex') - }) - const plugin = createMockOutputPlugin('MockOutputPlugin', [safeAppMarkdownFile], { - delete: [{kind: 'file', path: protectedAppMdxFile}] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.filesToDelete).toEqual([]) - expect(result.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - targetPath: path.resolve(protectedAppMdxFile), - protectionMode: 'direct', - protectedPath: path.resolve(protectedAppMdxFile) - }), - expect.objectContaining({targetPath: path.resolve(safeAppMarkdownFile)}) - ]) - ) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) - - it('throws when an output file path exactly matches a cleanup protect declaration', async () => { - const outputPath = path.resolve('tmp-out/protected.md') - const ctx = createCleanContext() - const plugin = createMockOutputPlugin('MockOutputPlugin', [outputPath], { - protect: [{kind: 'file', path: outputPath}] - }) - - await expect(collectDeletionTargets([plugin], ctx)).rejects.toThrow('Cleanup protection conflict') - }) - - it('blocks deleting an app directory when protected mdx descendants exist', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-cleanup-app-dir-')) - const workspaceDir = path.join(tempDir, 'workspace') - const appSubDir = path.join(workspaceDir, 'aindex', 'app', 'nested') - const protectedAppMdxFile = path.join(appSubDir, 'guide.mdx') - - fs.mkdirSync(appSubDir, {recursive: true}) - fs.writeFileSync(protectedAppMdxFile, '# app guide', 'utf8') - - try { - const ctx = createCleanContext({ - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] - }, - aindexDir: path.join(workspaceDir, 'aindex') - }) - const plugin = createMockOutputPlugin('MockOutputPlugin', [], { - delete: [{kind: 'directory', path: path.join(workspaceDir, 'aindex', 'app')}] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.dirsToDelete).toEqual([]) - expect(result.filesToDelete).toEqual([]) - expect(result.violations).toEqual([ - expect.objectContaining({ - targetPath: path.resolve(path.join(workspaceDir, 'aindex', 'app')), - protectionMode: 'direct', - protectedPath: path.resolve(protectedAppMdxFile) - }) - ]) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) - - it('blocks symlink targets that resolve to a protected path and keeps the most specific match', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-cleanup-guard-')) - const workspaceDir = path.join(tempDir, 'workspace') - const symlinkPath = path.join(tempDir, 'workspace-link') - - fs.mkdirSync(workspaceDir, {recursive: true}) - - try { - const symlinkType: 'junction' | 'dir' = process.platform === 'win32' ? 'junction' : 'dir' - fs.symlinkSync(workspaceDir, symlinkPath, symlinkType) - - const ctx = createCleanContext({ - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] - } - }) - const plugin = createMockOutputPlugin('MockOutputPlugin', [], { - delete: [{kind: 'directory', path: symlinkPath}] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.dirsToDelete).toEqual([]) - expect(result.violations).toEqual([ - expect.objectContaining({ - targetPath: path.resolve(symlinkPath), - protectedPath: path.resolve(path.join(workspaceDir, 'knowladge')), - protectionMode: 'direct' - }) - ]) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) - - it('lets direct protect declarations keep descendants deletable while recursive protect declarations block them', async () => { - const workspaceDir = path.resolve('tmp-direct-vs-recursive') - const directProtectedDir = path.join(workspaceDir, 'project-a') - const recursiveProtectedDir = path.join(workspaceDir, 'aindex', 'dist') - const directChildFile = path.join(directProtectedDir, 'AGENTS.md') - const recursiveChildFile = path.join(recursiveProtectedDir, 'commands', 'demo.mdx') - const ctx = createCleanContext({ - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] - } - }) - const plugin = createMockOutputPlugin('MockOutputPlugin', [directChildFile, recursiveChildFile], { - protect: [ - {kind: 'directory', path: directProtectedDir, protectionMode: 'direct'}, - {kind: 'directory', path: recursiveProtectedDir, protectionMode: 'recursive'} - ] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.filesToDelete).toEqual([path.resolve(directChildFile)]) - expect(result.violations).toEqual([ - expect.objectContaining({ - targetPath: path.resolve(recursiveChildFile), - protectionMode: 'recursive', - protectedPath: path.resolve(recursiveProtectedDir) - }) - ]) - }) - - it('skips delete glob matches covered by excludeScanGlobs while still deleting other sibling directories', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-cleanup-exclude-glob-')) - const skillsDir = path.join(tempDir, '.cursor', 'skills-cursor') - const preservedDir = path.join(skillsDir, 'create-rule') - const staleDir = path.join(skillsDir, 'legacy-skill') - - fs.mkdirSync(preservedDir, {recursive: true}) - fs.mkdirSync(staleDir, {recursive: true}) - fs.writeFileSync(path.join(preservedDir, 'SKILL.md'), '# preserved', 'utf8') - fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# stale', 'utf8') - - try { - const ctx = createCleanContext() - const plugin = createMockOutputPlugin('MockOutputPlugin', [], { - delete: [{kind: 'glob', path: path.join(skillsDir, '*')}], - protect: [{kind: 'directory', path: preservedDir}], - excludeScanGlobs: [preservedDir, path.join(preservedDir, '**')] - }) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.dirsToDelete).toEqual([path.resolve(staleDir)]) - expect(result.filesToDelete).toEqual([]) - expect(result.violations).toEqual([]) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) - - it('throws when an output path matches the configured workspace prompt source file', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-cleanup-workspace-src-')) - const workspaceDir = path.join(tempDir, 'workspace') - const aindexDir = path.join(workspaceDir, 'aindex-meta') - const workspacePromptSource = path.join(aindexDir, 'meta', 'workspace.src.mdx') - - fs.mkdirSync(path.dirname(workspacePromptSource), {recursive: true}) - fs.writeFileSync(workspacePromptSource, '# workspace', 'utf8') - - try { - const ctx = createCleanContext( - { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] - }, - aindexDir - }, - { - workspaceDir, - aindex: { - dir: 'aindex-meta', - workspacePrompt: { - src: 'meta/workspace.src.mdx', - dist: 'compiled/workspace.mdx' - } - } - } as Parameters[0] - ) - const plugin = createMockOutputPlugin('MockOutputPlugin', [workspacePromptSource]) - - await expect(collectDeletionTargets([plugin], ctx)).rejects.toThrow('Cleanup protection conflict') - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) - - it('plans workspace empty directories while skipping excluded trees and symlink entries', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-cleanup-empty-sweep-')) - const workspaceDir = path.join(tempDir, 'workspace') - const sourceLeafDir = path.join(workspaceDir, 'source', 'empty', 'leaf') - const sourceKeepFile = path.join(workspaceDir, 'source', 'keep.md') - const distEmptyDir = path.join(workspaceDir, 'dist', 'ghost') - const nodeModulesEmptyDir = path.join(workspaceDir, 'node_modules', 'pkg', 'ghost') - const gitEmptyDir = path.join(workspaceDir, '.git', 'objects', 'info') - const symlinkTarget = path.join(tempDir, 'symlink-target') - const symlinkParentDir = path.join(workspaceDir, 'symlink-parent') - const symlinkPath = path.join(symlinkParentDir, 'linked') - - fs.mkdirSync(sourceLeafDir, {recursive: true}) - fs.mkdirSync(path.dirname(sourceKeepFile), {recursive: true}) - fs.mkdirSync(distEmptyDir, {recursive: true}) - fs.mkdirSync(nodeModulesEmptyDir, {recursive: true}) - fs.mkdirSync(gitEmptyDir, {recursive: true}) - fs.mkdirSync(symlinkTarget, {recursive: true}) - fs.mkdirSync(symlinkParentDir, {recursive: true}) - fs.writeFileSync(sourceKeepFile, '# keep', 'utf8') - - try { - const symlinkType: 'junction' | 'dir' = process.platform === 'win32' ? 'junction' : 'dir' - fs.symlinkSync(symlinkTarget, symlinkPath, symlinkType) - - const ctx = createCleanContext({ - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] - } - }) - const plugin = createMockOutputPlugin('MockOutputPlugin', []) - - const result = await collectDeletionTargets([plugin], ctx) - - expect(result.filesToDelete).toEqual([]) - expect(result.dirsToDelete).toEqual([]) - expect(result.emptyDirsToDelete).toEqual([path.resolve(workspaceDir, 'source', 'empty'), path.resolve(sourceLeafDir)]) - expect(result.emptyDirsToDelete).not.toContain(path.resolve(workspaceDir)) - expect(result.emptyDirsToDelete).not.toContain(path.resolve(distEmptyDir)) - expect(result.emptyDirsToDelete).not.toContain(path.resolve(nodeModulesEmptyDir)) - expect(result.emptyDirsToDelete).not.toContain(path.resolve(gitEmptyDir)) - expect(result.emptyDirsToDelete).not.toContain(path.resolve(symlinkParentDir)) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) -}) - -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: 3, - errors: [], - violations: [], - conflicts: [] - }) - ) - expect(fs.existsSync(outputFile)).toBe(false) - expect(fs.existsSync(outputDir)).toBe(false) - expect(fs.existsSync(path.dirname(outputFile))).toBe(false) - expect(fs.existsSync(path.dirname(outputDir))).toBe(false) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) - - it('logs aggregated cleanup execution summaries instead of per-path success logs', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-perform-cleanup-logging-')) - const outputFile = path.join(tempDir, 'project-a', 'AGENTS.md') - const outputDir = path.join(tempDir, '.codex', 'prompts') - const stalePrompt = path.join(outputDir, 'demo.md') - const logger = createRecordingLogger() - - 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}] - }) - - await performCleanup([plugin], ctx, logger) - - expect(logger.debugMessages).toEqual( - expect.arrayContaining(['cleanup plan built', 'cleanup delete execution started', 'cleanup delete execution complete']) - ) - expect(logger.debugMessages).not.toContainEqual(expect.objectContaining({path: outputFile})) - expect(logger.debugMessages).not.toContainEqual(expect.objectContaining({path: outputDir})) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) - - it('deletes generated files and then prunes workspace empty directories', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-perform-cleanup-empty-sweep-')) - const outputFile = path.join(tempDir, 'generated', 'AGENTS.md') - const emptyLeafDir = path.join(tempDir, 'scratch', 'empty', 'leaf') - const retainedScratchFile = path.join(tempDir, 'scratch', 'keep.md') - - fs.mkdirSync(path.dirname(outputFile), {recursive: true}) - fs.mkdirSync(emptyLeafDir, {recursive: true}) - fs.mkdirSync(path.dirname(retainedScratchFile), {recursive: true}) - fs.writeFileSync(outputFile, '# agent', 'utf8') - fs.writeFileSync(retainedScratchFile, '# keep', 'utf8') - - try { - const ctx = createCleanContext({ - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: tempDir, - getDirectoryName: () => path.basename(tempDir), - getAbsolutePath: () => tempDir - }, - projects: [] - } - }) - const plugin = createMockOutputPlugin('MockOutputPlugin', [outputFile]) - - const result = await performCleanup([plugin], ctx, createMockLogger()) - - expect(result).toEqual( - expect.objectContaining({ - deletedFiles: 1, - deletedDirs: 3, - errors: [], - violations: [], - conflicts: [] - }) - ) - expect(fs.existsSync(outputFile)).toBe(false) - expect(fs.existsSync(path.dirname(outputFile))).toBe(false) - expect(fs.existsSync(path.join(tempDir, 'scratch', 'empty', 'leaf'))).toBe(false) - expect(fs.existsSync(path.join(tempDir, 'scratch', 'empty'))).toBe(false) - expect(fs.existsSync(path.join(tempDir, 'scratch'))).toBe(true) - } finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - }) -}) diff --git a/cli/src/commands/Command.ts b/cli/src/commands/Command.ts index 7f83bc06..789aadb9 100644 --- a/cli/src/commands/Command.ts +++ b/cli/src/commands/Command.ts @@ -1,16 +1,14 @@ -import type {ILogger, LoggerDiagnosticRecord} from '@truenine/logger' import type { + ILogger, + LoggerDiagnosticRecord, OutputCleanContext, OutputCollectedContext, OutputPlugin, OutputWriteContext, PluginOptions, UserConfigFile -} from '../plugins/plugin-core' +} from '@truenine/memory-sync-sdk' -/** - * Command execution context - */ export interface CommandContext { readonly logger: ILogger readonly outputPlugins: readonly OutputPlugin[] @@ -20,9 +18,6 @@ export interface CommandContext { readonly createWriteContext: (dryRun: boolean) => OutputWriteContext } -/** - * Command execution result - */ export interface CommandResult { readonly success: boolean readonly filesAffected: number @@ -30,10 +25,6 @@ export interface CommandResult { readonly message?: string } -/** - * Per-plugin execution result for JSON output mode. - * Captures individual plugin execution status, timing, and error details. - */ export interface PluginExecutionResult { readonly pluginName: string readonly kind: 'Input' | 'Output' @@ -43,11 +34,6 @@ export interface PluginExecutionResult { readonly duration?: number } -/** - * Structured JSON output for command execution results. - * Extends CommandResult with per-plugin details and error aggregation - * for consumption by Tauri sidecar / external tooling. - */ export interface JsonCommandResult { readonly success: boolean readonly filesAffected: number @@ -58,27 +44,17 @@ export interface JsonCommandResult { readonly errors: readonly LoggerDiagnosticRecord[] } -/** - * JSON output for configuration information. - * Contains the merged config and the source layers that contributed to it. - */ export interface JsonConfigInfo { readonly merged: UserConfigFile readonly sources: readonly ConfigSource[] } -/** - * Describes a single configuration source layer. - */ export interface ConfigSource { readonly path: string readonly layer: 'programmatic' | 'global' | 'default' readonly config: Partial } -/** - * JSON output for plugin information listing. - */ export interface JsonPluginInfo { readonly name: string readonly kind: 'Input' | 'Output' @@ -86,9 +62,6 @@ export interface JsonPluginInfo { readonly dependencies: readonly string[] } -/** - * Base command interface - */ export interface Command { readonly name: string execute: (ctx: CommandContext) => Promise diff --git a/cli/src/commands/CommandFactory.ts b/cli/src/commands/CommandFactory.ts index 3604485f..27acf7d8 100644 --- a/cli/src/commands/CommandFactory.ts +++ b/cli/src/commands/CommandFactory.ts @@ -1,29 +1,17 @@ import type {Command} from './Command' import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' -/** - * Command factory interface - * Each factory knows how to create a specific command based on CLI args - */ export interface CommandFactory { canHandle: (args: ParsedCliArgs) => boolean - createCommand: (args: ParsedCliArgs) => Command } -/** - * Priority levels for command factory resolution - * Lower number = higher priority - */ export enum FactoryPriority { - Flags = 0, // --version, --help flags (highest priority) - Unknown = 1, // Unknown command handling - Subcommand = 2 // Named subcommands + Flags = 0, + Unknown = 1, + Subcommand = 2 } -/** - * Extended factory interface with priority - */ export interface PrioritizedCommandFactory extends CommandFactory { readonly priority: FactoryPriority } diff --git a/cli/src/commands/CommandRegistry.ts b/cli/src/commands/CommandRegistry.ts index 91d16351..736055bb 100644 --- a/cli/src/commands/CommandRegistry.ts +++ b/cli/src/commands/CommandRegistry.ts @@ -3,20 +3,16 @@ import type {CommandFactory, PrioritizedCommandFactory} from './CommandFactory' import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' import {FactoryPriority} from './CommandFactory' -/** - * Command registry that manages command factories - * Uses priority-based resolution for factory selection - */ export class CommandRegistry { private readonly factories: PrioritizedCommandFactory[] = [] register(factory: PrioritizedCommandFactory): void { this.factories.push(factory) - this.factories.sort((a, b) => a.priority - b.priority) // Sort by priority (lower number = higher priority) + this.factories.sort((a, b) => a.priority - b.priority) } registerWithPriority(factory: CommandFactory, priority: FactoryPriority): void { - const prioritized: PrioritizedCommandFactory = { // Create a wrapper that delegates to the original factory while adding priority + const prioritized: PrioritizedCommandFactory = { priority, canHandle: (args: ParsedCliArgs) => factory.canHandle(args), createCommand: (args: ParsedCliArgs) => factory.createCommand(args) @@ -26,18 +22,18 @@ export class CommandRegistry { } resolve(args: ParsedCliArgs): Command { - for (const factory of this.factories) { // First pass: check prioritized factories (flags, unknown commands) + for (const factory of this.factories) { if (factory.priority <= FactoryPriority.Unknown && factory.canHandle(args)) return factory.createCommand(args) } - for (const factory of this.factories) { // Second pass: check subcommand factories + for (const factory of this.factories) { if (factory.priority === FactoryPriority.Subcommand && factory.canHandle(args)) return factory.createCommand(args) } - for (const factory of this.factories) { // Third pass: use catch-all factory (ExecuteCommandFactory) + for (const factory of this.factories) { if (factory.canHandle(args)) return factory.createCommand(args) } - throw new Error('No command factory found for the given arguments') // This should never happen if ExecuteCommandFactory is registered + throw new Error('No command factory found for the given arguments') } } diff --git a/cli/src/commands/ConfigCommand.ts b/cli/src/commands/ConfigCommand.ts index 68b10277..90defede 100644 --- a/cli/src/commands/ConfigCommand.ts +++ b/cli/src/commands/ConfigCommand.ts @@ -1,80 +1,42 @@ +import type {AindexConfigKeyPath} from '@truenine/memory-sync-sdk' import type {Command, CommandContext, CommandResult} from './Command' -import type {AindexConfigKeyPath} from '@/plugins/plugin-core' import * as fs from 'node:fs' import * as path from 'node:path' -import {buildUsageDiagnostic, diagnosticLines} from '@/diagnostics' -import {AINDEX_CONFIG_KEY_PATHS} from '@/plugins/plugin-core' -import {getRequiredGlobalConfigPath} from '@/runtime-environment' - -/** - * Valid configuration keys that can be set via `tnmsc config key=value`. - * Nested keys use dot-notation: aindex.skills.src, aindex.commands.src, etc. - */ -type ValidConfigKey = 'workspaceDir' | 'logLevel' | AindexConfigKeyPath +import {AINDEX_CONFIG_KEY_PATHS, buildUsageDiagnostic, diagnosticLines, getRequiredGlobalConfigPath} from '@truenine/memory-sync-sdk' -const VALID_CONFIG_KEYS: readonly ValidConfigKey[] = [ - 'workspaceDir', - ...AINDEX_CONFIG_KEY_PATHS, - 'logLevel' -] +type ValidConfigKey = 'workspaceDir' | 'logLevel' | AindexConfigKeyPath +const VALID_CONFIG_KEYS: readonly ValidConfigKey[] = ['workspaceDir', ...AINDEX_CONFIG_KEY_PATHS, 'logLevel'] -/** - * Validate if a key is a valid config key - */ function isValidConfigKey(key: string): key is ValidConfigKey { return VALID_CONFIG_KEYS.includes(key as ValidConfigKey) } -/** - * Validate log level value - */ function isValidLogLevel(value: string): boolean { - const validLevels = ['trace', 'debug', 'info', 'warn', 'error'] - return validLevels.includes(value) + return ['trace', 'debug', 'info', 'warn', 'error'].includes(value) } -/** - * Get global config file path - */ -function getGlobalConfigPath(): string { - return getRequiredGlobalConfigPath() +type ConfigValue = string | ConfigObject +interface ConfigObject { + [key: string]: ConfigValue | undefined } -/** - * Read global config file - */ function readGlobalConfig(): ConfigObject { - const configPath = getGlobalConfigPath() + const configPath = getRequiredGlobalConfigPath() if (!fs.existsSync(configPath)) return {} try { - const content = fs.readFileSync(configPath, 'utf8') - return JSON.parse(content) as ConfigObject - } - catch { + return JSON.parse(fs.readFileSync(configPath, 'utf8')) as ConfigObject + } catch { return {} } } -/** - * Write global config file - */ function writeGlobalConfig(config: ConfigObject): void { - const configPath = getGlobalConfigPath() + const configPath = getRequiredGlobalConfigPath() const configDir = path.dirname(configPath) - - if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, {recursive: true}) // Ensure directory exists - - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8') // Write with pretty formatting + if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, {recursive: true}) + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8') } -type ConfigValue = string | ConfigObject -interface ConfigObject { - [key: string]: ConfigValue | undefined -} - -/** - * Set a nested value in an object using dot-notation key - */ function setNestedValue(obj: ConfigObject, key: string, value: string): void { const parts = key.split('.') let current: ConfigObject = obj @@ -85,15 +47,10 @@ function setNestedValue(obj: ConfigObject, key: string, value: string): void { if (typeof next !== 'object' || next === null || Array.isArray(next)) current[part] = {} current = current[part] as ConfigObject } - const lastPart = parts.at(-1) - if (lastPart == null) return - current[lastPart] = value + if (lastPart != null) current[lastPart] = value } -/** - * Get a nested value from an object using dot-notation key - */ function getNestedValue(obj: ConfigObject, key: string): ConfigValue | undefined { const parts = key.split('.') let current: ConfigValue | undefined = obj @@ -107,131 +64,87 @@ function getNestedValue(obj: ConfigObject, key: string): ConfigValue | undefined export class ConfigCommand implements Command { readonly name = 'config' - constructor( - private readonly options: readonly [key: string, value: string][] - ) { } + constructor(private readonly options: readonly [key: string, value: string][]) {} async execute(ctx: CommandContext): Promise { const {logger} = ctx if (this.options.length === 0) { - logger.error(buildUsageDiagnostic({ - code: 'CONFIG_COMMAND_ARGUMENTS_MISSING', - title: 'Config command requires at least one key=value pair', - rootCause: diagnosticLines( - 'tnmsc config was invoked without any configuration assignments.' - ), - exactFix: diagnosticLines( - 'Run `tnmsc config key=value` with at least one supported configuration key.' - ), - possibleFixes: [ - diagnosticLines(`Use one of the supported keys: ${VALID_CONFIG_KEYS.join(', ')}`) - ], - details: { - validKeys: [...VALID_CONFIG_KEYS] - } - })) + logger.error( + buildUsageDiagnostic({ + code: 'CONFIG_COMMAND_ARGUMENTS_MISSING', + title: 'Config command requires at least one key=value pair', + rootCause: diagnosticLines('tnmsc config was invoked without any configuration assignments.'), + exactFix: diagnosticLines('Run `tnmsc config key=value` with at least one supported configuration key.'), + possibleFixes: [diagnosticLines(`Use one of the supported keys: ${VALID_CONFIG_KEYS.join(', ')}`)], + details: {validKeys: [...VALID_CONFIG_KEYS]} + }) + ) logger.info('Usage: tnmsc config key=value') logger.info(`Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`) - return { - success: false, - filesAffected: 0, - dirsAffected: 0, - message: 'No options provided' - } + return {success: false, filesAffected: 0, dirsAffected: 0, message: 'No options provided'} } let config: ConfigObject - try { config = readGlobalConfig() - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return { - success: false, - filesAffected: 0, - dirsAffected: 0, - message: errorMessage - } + } catch (error) { + return {success: false, filesAffected: 0, dirsAffected: 0, message: error instanceof Error ? error.message : String(error)} } const errors: string[] = [] const updated: string[] = [] - - for (const [key, value] of this.options) { // Process each key-value pair + for (const [key, value] of this.options) { if (!isValidConfigKey(key)) { errors.push(`Invalid key: ${key}`) - logger.error(buildUsageDiagnostic({ - code: 'CONFIG_COMMAND_KEY_INVALID', - title: `Unsupported config key: ${key}`, - rootCause: diagnosticLines( - `The config command received "${key}", which is not a supported configuration key.` - ), - exactFix: diagnosticLines('Use one of the supported config keys and rerun the command.'), - possibleFixes: [ - diagnosticLines(`Supported keys: ${VALID_CONFIG_KEYS.join(', ')}`) - ], - details: { - key, - validKeys: [...VALID_CONFIG_KEYS] - } - })) + logger.error( + buildUsageDiagnostic({ + code: 'CONFIG_COMMAND_KEY_INVALID', + title: `Unsupported config key: ${key}`, + rootCause: diagnosticLines(`The config command received "${key}", which is not a supported configuration key.`), + exactFix: diagnosticLines('Use one of the supported config keys and rerun the command.'), + possibleFixes: [diagnosticLines(`Supported keys: ${VALID_CONFIG_KEYS.join(', ')}`)], + details: {key, validKeys: [...VALID_CONFIG_KEYS]} + }) + ) continue } - if (key === 'logLevel' && !isValidLogLevel(value)) { // Special validation for logLevel + if (key === 'logLevel' && !isValidLogLevel(value)) { errors.push(`Invalid logLevel value: ${value}`) - logger.error(buildUsageDiagnostic({ - code: 'CONFIG_COMMAND_LOG_LEVEL_INVALID', - title: `Unsupported logLevel value: ${value}`, - rootCause: diagnosticLines( - `The config command received "${value}" for logLevel, but tnmsc does not support that level.` - ), - exactFix: diagnosticLines('Set logLevel to one of: trace, debug, info, warn, or error.'), - details: { - key, - value, - validLevels: ['trace', 'debug', 'info', 'warn', 'error'] - } - })) + logger.error( + buildUsageDiagnostic({ + code: 'CONFIG_COMMAND_LOG_LEVEL_INVALID', + title: `Unsupported logLevel value: ${value}`, + rootCause: diagnosticLines(`The config command received "${value}" for logLevel, but tnmsc does not support that level.`), + exactFix: diagnosticLines('Set logLevel to one of: trace, debug, info, warn, or error.'), + details: {key, value, validLevels: ['trace', 'debug', 'info', 'warn', 'error']} + }) + ) continue } - const oldValue = getNestedValue(config, key) // Update config + const oldValue = getNestedValue(config, key) setNestedValue(config, key, value) - if (oldValue !== value) updated.push(`${key}=${value}`) - logger.info('configuration updated', {key, value}) } - if (updated.length > 0) { // Write config if there are valid updates + if (updated.length > 0) { try { writeGlobalConfig(config) + } catch (error) { + return {success: false, filesAffected: 0, dirsAffected: 0, message: error instanceof Error ? error.message : String(error)} } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return { - success: false, - filesAffected: 0, - dirsAffected: 0, - message: errorMessage - } - } - logger.info('global config written', {path: getGlobalConfigPath()}) + logger.info('global config written', {path: getRequiredGlobalConfigPath()}) } const success = errors.length === 0 - const message = success - ? `Configuration updated: ${updated.join(', ')}` - : `Partial update: ${updated.join(', ')}. Errors: ${errors.join(', ')}` - return { success, filesAffected: updated.length > 0 ? 1 : 0, dirsAffected: 0, - message + message: success ? `Configuration updated: ${updated.join(', ')}` : `Partial update: ${updated.join(', ')}. Errors: ${errors.join(', ')}` } } } diff --git a/cli/src/commands/ConfigShowCommand.ts b/cli/src/commands/ConfigShowCommand.ts index 2a21822a..f07072d6 100644 --- a/cli/src/commands/ConfigShowCommand.ts +++ b/cli/src/commands/ConfigShowCommand.ts @@ -1,17 +1,7 @@ import type {Command, CommandContext, CommandResult, ConfigSource, JsonConfigInfo} from './Command' import process from 'node:process' -import {ConfigLoader} from '@/ConfigLoader' +import {ConfigLoader} from '@truenine/memory-sync-sdk' -/** - * Command that outputs the current merged configuration and its source layers as JSON. - * - * Invoked via `tnmsc config --show --json`. - * Writes a `JsonConfigInfo` object to stdout containing: - * - `merged`: the final merged UserConfigFile - * - `sources`: an array of ConfigSource entries describing each layer - * - * When used without `--json`, logs the config info via the logger. - */ export class ConfigShowCommand implements Command { readonly name = 'config-show' @@ -19,30 +9,13 @@ export class ConfigShowCommand implements Command { const {logger} = ctx const loader = new ConfigLoader() const mergedResult = loader.load() - const sources: ConfigSource[] = mergedResult.sources.map(sourcePath => { const loaded = loader.loadFromFile(sourcePath) - return { - path: sourcePath, - layer: 'global', - config: loaded.config - } + return {path: sourcePath, layer: 'global', config: loaded.config} }) - - const configInfo: JsonConfigInfo = { - merged: mergedResult.config, - sources - } - + const configInfo: JsonConfigInfo = {merged: mergedResult.config, sources} process.stdout.write(`${JSON.stringify(configInfo)}\n`) - logger.info('config shown', {sources: mergedResult.sources.length}) - - return { - success: true, - filesAffected: 0, - dirsAffected: 0, - message: `Configuration displayed (${sources.length} source(s))` - } + return {success: true, filesAffected: 0, dirsAffected: 0, message: `Configuration displayed (${sources.length} source(s))`} } } diff --git a/cli/src/commands/DryRunCleanCommand.ts b/cli/src/commands/DryRunCleanCommand.ts index 72ce58c5..12b20913 100644 --- a/cli/src/commands/DryRunCleanCommand.ts +++ b/cli/src/commands/DryRunCleanCommand.ts @@ -1,22 +1,15 @@ import type {Command, CommandContext, CommandResult} from './Command' import * as path from 'node:path' -import {collectAllPluginOutputs} from '../plugins/plugin-core' -import {logProtectedDeletionGuardError} from '../ProtectedDeletionGuard' -import {collectDeletionTargets} from './CleanupUtils' +import {collectAllPluginOutputs, collectDeletionTargets, logProtectedDeletionGuardError} from '@truenine/memory-sync-sdk' -/** - * Dry-run clean command - simulates clean operations without actual deletion - */ export class DryRunCleanCommand implements Command { readonly name = 'dry-run-clean' async execute(ctx: CommandContext): Promise { const {logger, outputPlugins, createCleanContext} = ctx logger.info('running clean pipeline', {command: 'dry-run-clean', dryRun: true}) - const cleanCtx = createCleanContext(true) const outputs = await collectAllPluginOutputs(outputPlugins, cleanCtx) - logger.info('collected outputs for cleanup', { dryRun: true, projectDirs: outputs.projectDirs.length, @@ -38,8 +31,9 @@ export class DryRunCleanCommand implements Command { } } - this.logDryRunFiles(filesToDelete, logger) - this.logDryRunDirectories(totalDirsToDelete, logger) + for (const file of filesToDelete) logger.info('would delete file', {path: path.isAbsolute(file) ? file : path.resolve(file), dryRun: true}) + for (const dir of [...totalDirsToDelete].sort((a, b) => b.length - a.length)) + { logger.info('would delete directory', {path: path.isAbsolute(dir) ? dir : path.resolve(dir), dryRun: true}) } logger.info('clean complete', { dryRun: true, @@ -56,19 +50,4 @@ export class DryRunCleanCommand implements Command { message: 'Dry-run complete, no files were deleted' } } - - private logDryRunFiles(files: string[], logger: CommandContext['logger']): void { - for (const file of files) { - const resolved = path.isAbsolute(file) ? file : path.resolve(file) - logger.info('would delete file', {path: resolved, dryRun: true}) - } - } - - private logDryRunDirectories(dirs: string[], logger: CommandContext['logger']): void { - const sortedDirs = [...dirs].sort((a, b) => b.length - a.length) - for (const dir of sortedDirs) { - const resolved = path.isAbsolute(dir) ? dir : path.resolve(dir) - logger.info('would delete directory', {path: resolved, dryRun: true}) - } - } } diff --git a/cli/src/commands/DryRunOutputCommand.ts b/cli/src/commands/DryRunOutputCommand.ts index 180501f6..fbf92733 100644 --- a/cli/src/commands/DryRunOutputCommand.ts +++ b/cli/src/commands/DryRunOutputCommand.ts @@ -1,20 +1,12 @@ import type {Command, CommandContext, CommandResult} from './Command' -import {syncWindowsConfigIntoWsl} from '@/wsl-mirror-sync' -import { - collectOutputDeclarations, - executeDeclarativeWriteOutputs -} from '../plugins/plugin-core' +import {collectOutputDeclarations, executeDeclarativeWriteOutputs, syncWindowsConfigIntoWsl} from '@truenine/memory-sync-sdk' -/** - * Dry-run output command - simulates write operations without actual I/O - */ export class DryRunOutputCommand implements Command { readonly name = 'dry-run-output' async execute(ctx: CommandContext): Promise { const {logger, outputPlugins, createWriteContext} = ctx logger.info('started', {command: 'dry-run-output', dryRun: true}) - const writeCtx = createWriteContext(true) const predeclaredOutputs = await collectOutputDeclarations(outputPlugins, writeCtx) const results = await executeDeclarativeWriteOutputs(outputPlugins, writeCtx, predeclaredOutputs) @@ -29,23 +21,11 @@ export class DryRunOutputCommand implements Command { const wslMirrorResult = await syncWindowsConfigIntoWsl(outputPlugins, writeCtx, void 0, predeclaredOutputs) if (wslMirrorResult.errors.length > 0) { - return { - success: false, - filesAffected: totalFiles, - dirsAffected: totalDirs, - message: wslMirrorResult.errors.join('\n') - } + return {success: false, filesAffected: totalFiles, dirsAffected: totalDirs, message: wslMirrorResult.errors.join('\n')} } totalFiles += wslMirrorResult.mirroredFiles - logger.info('complete', {command: 'dry-run-output', totalFiles, totalDirs, dryRun: true}) - - return { - success: true, - filesAffected: totalFiles, - dirsAffected: totalDirs, - message: 'Dry-run complete, no files were written' - } + return {success: true, filesAffected: totalFiles, dirsAffected: totalDirs, message: 'Dry-run complete, no files were written'} } } diff --git a/cli/src/commands/ExecuteCommand.ts b/cli/src/commands/ExecuteCommand.ts index 8f4c1c96..2a100c6f 100644 --- a/cli/src/commands/ExecuteCommand.ts +++ b/cli/src/commands/ExecuteCommand.ts @@ -1,15 +1,6 @@ import type {Command, CommandContext, CommandResult} from './Command' -import {syncWindowsConfigIntoWsl} from '@/wsl-mirror-sync' -import { - collectOutputDeclarations, - executeDeclarativeWriteOutputs -} from '../plugins/plugin-core' -import {performCleanup} from './CleanupUtils' +import {collectOutputDeclarations, executeDeclarativeWriteOutputs, performCleanup, syncWindowsConfigIntoWsl} from '@truenine/memory-sync-sdk' -/** - * Execute command - performs actual write operations - * Includes pre-cleanup to remove stale files before writing new outputs - */ export class ExecuteCommand implements Command { readonly name = 'execute' @@ -19,21 +10,13 @@ export class ExecuteCommand implements Command { const writeCtx = createWriteContext(false) const predeclaredOutputs = await collectOutputDeclarations(outputPlugins, writeCtx) - const cleanCtx = createCleanContext(false) // Step 1: Pre-cleanup (non-dry-run only) - const cleanupResult = await performCleanup(outputPlugins, cleanCtx, logger, predeclaredOutputs) - + const cleanupResult = await performCleanup(outputPlugins, createCleanContext(false), logger, predeclaredOutputs) if (cleanupResult.violations.length > 0 || cleanupResult.conflicts.length > 0) { - return { - success: false, - filesAffected: 0, - dirsAffected: 0, - ...cleanupResult.message != null ? {message: cleanupResult.message} : {} - } + return {success: false, filesAffected: 0, dirsAffected: 0, ...cleanupResult.message != null ? {message: cleanupResult.message} : {}} } logger.info('cleanup complete', {deletedFiles: cleanupResult.deletedFiles, deletedDirs: cleanupResult.deletedDirs}) - - const results = await executeDeclarativeWriteOutputs(outputPlugins, writeCtx, predeclaredOutputs) // Step 2: Write outputs + const results = await executeDeclarativeWriteOutputs(outputPlugins, writeCtx, predeclaredOutputs) let totalFiles = 0 let totalDirs = 0 @@ -47,33 +30,16 @@ export class ExecuteCommand implements Command { } if (writeErrors.length > 0) { - return { - success: false, - filesAffected: totalFiles, - dirsAffected: totalDirs, - message: writeErrors.join('\n') - } + return {success: false, filesAffected: totalFiles, dirsAffected: totalDirs, message: writeErrors.join('\n')} } const wslMirrorResult = await syncWindowsConfigIntoWsl(outputPlugins, writeCtx, void 0, predeclaredOutputs) - if (wslMirrorResult.errors.length > 0) { - return { - success: false, - filesAffected: totalFiles, - dirsAffected: totalDirs, - message: wslMirrorResult.errors.join('\n') - } + return {success: false, filesAffected: totalFiles, dirsAffected: totalDirs, message: wslMirrorResult.errors.join('\n')} } totalFiles += wslMirrorResult.mirroredFiles - logger.info('complete', {command: 'execute', pluginCount: results.size}) - - return { - success: true, - filesAffected: totalFiles, - dirsAffected: totalDirs - } + return {success: true, filesAffected: totalFiles, dirsAffected: totalDirs} } } diff --git a/cli/src/commands/HelpCommand.ts b/cli/src/commands/HelpCommand.ts index ae7201d1..1ca4f8f9 100644 --- a/cli/src/commands/HelpCommand.ts +++ b/cli/src/commands/HelpCommand.ts @@ -1,5 +1,5 @@ import type {Command, CommandContext, CommandResult} from './Command' -import {AINDEX_CONFIG_KEY_PATHS} from '@/plugins/plugin-core' +import {AINDEX_CONFIG_KEY_PATHS} from '@truenine/memory-sync-sdk' import {getCliVersion} from './VersionCommand' const CLI_NAME = 'tnmsc' @@ -58,20 +58,11 @@ CONFIGURATION: See documentation for detailed configuration options. `.trim() -/** - * Help command - displays CLI usage information - */ export class HelpCommand implements Command { readonly name = 'help' async execute(ctx: CommandContext): Promise { ctx.logger.info(HELP_TEXT) - - return { - success: true, - filesAffected: 0, - dirsAffected: 0, - message: 'Help displayed' - } + return {success: true, filesAffected: 0, dirsAffected: 0, message: 'Help displayed'} } } diff --git a/cli/src/commands/InitCommand.test.ts b/cli/src/commands/InitCommand.test.ts index 3224c8f6..0b265c43 100644 --- a/cli/src/commands/InitCommand.test.ts +++ b/cli/src/commands/InitCommand.test.ts @@ -1,10 +1,8 @@ import type {CommandContext} from './Command' import * as fs from 'node:fs' import * as path from 'node:path' -import glob from 'fast-glob' +import {createLogger, FilePathKind, mergeConfig} from '@truenine/memory-sync-sdk' import {describe, expect, it} from 'vitest' -import {mergeConfig} from '../config' -import {createLogger, FilePathKind} from '../plugins/plugin-core' import {InitCommand} from './InitCommand' function createCommandContext(): CommandContext { @@ -20,55 +18,55 @@ function createCommandContext(): CommandContext { directory: { pathKind: FilePathKind.Absolute, path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir + getDirectoryName: () => path.basename(workspaceDir) }, projects: [] } }, - createCleanContext: dryRun => ({ - logger: createLogger('InitCommandTest', 'error'), - fs, - path, - glob, - dryRun, - collectedOutputContext: { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] + createCleanContext: dryRun => + ({ + logger: createLogger('InitCommandTest', 'error'), + fs, + path, + glob: {} as never, + runtimeTargets: {jetbrainsCodexDirs: []}, + dryRun, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceDir, + getDirectoryName: () => path.basename(workspaceDir) + }, + projects: [] + } } - } - }) as CommandContext['createCleanContext'] extends (dryRun: boolean) => infer T ? T : never, - createWriteContext: dryRun => ({ - logger: createLogger('InitCommandTest', 'error'), - fs, - path, - glob, - dryRun, - collectedOutputContext: { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [] + }) as unknown as CommandContext['createCleanContext'] extends (dryRun: boolean) => infer T ? T : never, + createWriteContext: dryRun => + ({ + logger: createLogger('InitCommandTest', 'error'), + fs, + path, + glob: {} as never, + runtimeTargets: {jetbrainsCodexDirs: []}, + dryRun, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceDir, + getDirectoryName: () => path.basename(workspaceDir) + }, + projects: [] + } } - } - }) as CommandContext['createWriteContext'] extends (dryRun: boolean) => infer T ? T : never + }) as unknown as CommandContext['createWriteContext'] extends (dryRun: boolean) => infer T ? T : never } } describe('init command', () => { it('returns a deprecation failure without creating files', async () => { const result = await new InitCommand().execute(createCommandContext()) - expect(result.success).toBe(false) expect(result.filesAffected).toBe(0) expect(result.dirsAffected).toBe(0) diff --git a/cli/src/commands/InitCommand.ts b/cli/src/commands/InitCommand.ts index 98180fcc..54ef706f 100644 --- a/cli/src/commands/InitCommand.ts +++ b/cli/src/commands/InitCommand.ts @@ -1,36 +1,24 @@ import type {Command, CommandContext, CommandResult} from './Command' -import {buildUsageDiagnostic, diagnosticLines} from '@/diagnostics' +import {buildUsageDiagnostic, diagnosticLines} from '@truenine/memory-sync-sdk' -const INIT_DEPRECATION_MESSAGE = '`tnmsc init` is deprecated and no longer initializes aindex. Maintain the public target-relative definitions manually under `~/workspace/aindex/public/`.' +const INIT_DEPRECATION_MESSAGE + = '`tnmsc init` is deprecated and no longer initializes aindex. Maintain the public target-relative definitions manually under `~/workspace/aindex/public/`.' export class InitCommand implements Command { readonly name = 'init' async execute(ctx: CommandContext): Promise { const {logger} = ctx - - logger.warn(buildUsageDiagnostic({ - code: 'INIT_COMMAND_DEPRECATED', - title: 'The init command is deprecated', - rootCause: diagnosticLines( - '`tnmsc init` no longer initializes aindex content or project definitions.' - ), - exactFix: diagnosticLines( - 'Maintain the target-relative definitions manually under `~/workspace/aindex/public/`.' - ), - possibleFixes: [ - diagnosticLines('Run `tnmsc help` to find a supported replacement command for your workflow.') - ], - details: { - command: 'init' - } - })) - - return { - success: false, - filesAffected: 0, - dirsAffected: 0, - message: INIT_DEPRECATION_MESSAGE - } + logger.warn( + buildUsageDiagnostic({ + code: 'INIT_COMMAND_DEPRECATED', + title: 'The init command is deprecated', + rootCause: diagnosticLines('`tnmsc init` no longer initializes aindex content or project definitions.'), + exactFix: diagnosticLines('Maintain the target-relative definitions manually under `~/workspace/aindex/public/`.'), + possibleFixes: [diagnosticLines('Run `tnmsc help` to find a supported replacement command for your workflow.')], + details: {command: 'init'} + }) + ) + return {success: false, filesAffected: 0, dirsAffected: 0, message: INIT_DEPRECATION_MESSAGE} } } diff --git a/cli/src/commands/JsonOutputCommand.ts b/cli/src/commands/JsonOutputCommand.ts index 3123e96c..6d49cea0 100644 --- a/cli/src/commands/JsonOutputCommand.ts +++ b/cli/src/commands/JsonOutputCommand.ts @@ -1,49 +1,25 @@ import type {Command, CommandContext, CommandResult, JsonCommandResult} from './Command' import process from 'node:process' -import {partitionBufferedDiagnostics} from '@/diagnostics' -import {clearBufferedDiagnostics, drainBufferedDiagnostics} from '@/plugins/plugin-core' +import {clearBufferedDiagnostics, drainBufferedDiagnostics, partitionBufferedDiagnostics} from '@truenine/memory-sync-sdk' -/** - * Decorator command that wraps any Command to produce JSON output on stdout. - * - * When the `--json` flag is detected, this wrapper: - * 1. Suppresses all Winston console logging (sets global log level to 'silent') - * 2. Delegates execution to the inner command - * 3. Converts the CommandResult to a JsonCommandResult - * 4. Writes the JSON string to stdout - * - * This ensures clean, parseable JSON output for consumption by - * Tauri sidecar or other external tooling. - */ export class JsonOutputCommand implements Command { readonly name: string - private readonly inner: Command - constructor(inner: Command) { - this.inner = inner + constructor(private readonly inner: Command) { this.name = `json:${inner.name}` } async execute(ctx: CommandContext): Promise { clearBufferedDiagnostics() const result = await this.inner.execute(ctx) - const jsonResult = toJsonCommandResult(result, drainBufferedDiagnostics()) - process.stdout.write(`${JSON.stringify(jsonResult)}\n`) + process.stdout.write(`${JSON.stringify(toJsonCommandResult(result, drainBufferedDiagnostics()))}\n`) return result } } -/** - * Convert a CommandResult to a JsonCommandResult. - * Maps the base result fields and initialises optional arrays as empty - * when not present, ensuring a consistent JSON shape. - */ -export function toJsonCommandResult( - result: CommandResult, - diagnostics = drainBufferedDiagnostics() -): JsonCommandResult { +export function toJsonCommandResult(result: CommandResult, diagnostics = drainBufferedDiagnostics()): JsonCommandResult { const {warnings, errors} = partitionBufferedDiagnostics(diagnostics) - const json: JsonCommandResult = { + return { success: result.success, filesAffected: result.filesAffected, dirsAffected: result.dirsAffected, @@ -52,5 +28,4 @@ export function toJsonCommandResult( warnings, errors } - return json } diff --git a/cli/src/commands/PluginsCommand.ts b/cli/src/commands/PluginsCommand.ts index 8f284a06..35fd6d0e 100644 --- a/cli/src/commands/PluginsCommand.ts +++ b/cli/src/commands/PluginsCommand.ts @@ -1,25 +1,14 @@ import type {Command, CommandContext, CommandResult, JsonPluginInfo} from './Command' import process from 'node:process' -/** - * Command that outputs all registered output plugin information as JSON. - * - * Invoked via `tnmsc plugins --json`. - * Writes a `JsonPluginInfo[]` array to stdout containing each output plugin's - * name, description, and dependency list. - * - * When used without `--json`, logs the plugin list via the logger. - */ export class PluginsCommand implements Command { readonly name = 'plugins' async execute(ctx: CommandContext): Promise { const {logger, outputPlugins, userConfigOptions} = ctx - - const allPlugins = userConfigOptions.plugins const pluginInfos: JsonPluginInfo[] = [] - for (const plugin of allPlugins) { + for (const plugin of userConfigOptions.plugins) { pluginInfos.push({ name: plugin.name, kind: 'Output', @@ -28,27 +17,19 @@ export class PluginsCommand implements Command { }) } - const registeredNames = new Set(pluginInfos.map(p => p.name)) // (they are registered separately via registerOutputPlugins) // Also include output plugins that may not be in userConfigOptions.plugins + const registeredNames = new Set(pluginInfos.map(plugin => plugin.name)) for (const plugin of outputPlugins) { - if (!registeredNames.has(plugin.name)) { - pluginInfos.push({ - name: plugin.name, - kind: 'Output', - description: plugin.name, - dependencies: [...plugin.dependsOn ?? []] - }) - } + if (registeredNames.has(plugin.name)) continue + pluginInfos.push({ + name: plugin.name, + kind: 'Output', + description: plugin.name, + dependencies: [...plugin.dependsOn ?? []] + }) } process.stdout.write(`${JSON.stringify(pluginInfos)}\n`) - logger.info('plugins listed', {count: pluginInfos.length}) - - return { - success: true, - filesAffected: 0, - dirsAffected: 0, - message: `Listed ${pluginInfos.length} plugin(s)` - } + return {success: true, filesAffected: 0, dirsAffected: 0, message: `Listed ${pluginInfos.length} plugin(s)`} } } diff --git a/cli/src/commands/ProtectedDeletionCommands.test.ts b/cli/src/commands/ProtectedDeletionCommands.test.ts deleted file mode 100644 index 3b431b65..00000000 --- a/cli/src/commands/ProtectedDeletionCommands.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type {ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputPlugin, OutputWriteContext} from '../plugins/plugin-core' -import type {CommandContext} from './Command' -import * as fs from 'node:fs' -import * as path from 'node:path' -import glob from 'fast-glob' -import {describe, expect, it, vi} from 'vitest' -import {mergeConfig} from '../config' -import {createLogger, FilePathKind, PluginKind} from '../plugins/plugin-core' -import {CleanCommand} from './CleanCommand' -import {DryRunCleanCommand} from './DryRunCleanCommand' -import {ExecuteCommand} from './ExecuteCommand' -import {JsonOutputCommand} from './JsonOutputCommand' - -function createMockLogger(): ILogger { - return { - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - fatal: () => {} - } as ILogger -} - -function createMockOutputPlugin( - cleanup?: OutputCleanupDeclarations, - convertContent?: OutputPlugin['convertContent'] -): OutputPlugin { - return { - type: PluginKind.Output, - name: 'MockOutputPlugin', - log: createMockLogger(), - declarativeOutput: true, - outputCapabilities: {}, - async declareOutputFiles() { - return [{path: path.join(path.resolve('tmp-workspace-command'), 'project-a', 'AGENTS.md'), source: {}}] - }, - async declareCleanupPaths() { - return cleanup ?? {} - }, - async convertContent(declaration, ctx) { - if (convertContent != null) return convertContent(declaration, ctx) - return 'test' - } - } -} - -function createCommandContext( - outputPlugins: readonly OutputPlugin[], - workspaceDir: string = path.resolve('tmp-workspace-command') -): CommandContext { - const aindexDir = path.join(workspaceDir, 'aindex') - const userConfigOptions = mergeConfig({workspaceDir}) - const collectedOutputContext = { - workspace: { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir - }, - projects: [{ - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: 'project-a', - basePath: workspaceDir, - getDirectoryName: () => 'project-a', - getAbsolutePath: () => path.join(workspaceDir, 'project-a') - } - }] - }, - aindexDir - } - - return { - logger: createMockLogger(), - outputPlugins, - collectedOutputContext, - userConfigOptions, - createCleanContext: (dryRun: boolean): OutputCleanContext => ({ - logger: createMockLogger(), - fs, - path, - glob, - collectedOutputContext, - pluginOptions: userConfigOptions, - dryRun - }), - createWriteContext: (dryRun: boolean): OutputWriteContext => ({ - logger: createMockLogger(), - fs, - path, - glob, - collectedOutputContext, - dryRun, - registeredPluginNames: outputPlugins.map(plugin => plugin.name) - }) - } -} - -describe('protected deletion commands', () => { - it('returns failure for clean and dry-run-clean when cleanup hits a protected path', async () => { - const workspaceDir = path.resolve('tmp-workspace-command') - const plugin = createMockOutputPlugin({ - delete: [{kind: 'directory', path: workspaceDir}] - }) - const ctx = createCommandContext([plugin]) - - await expect(new CleanCommand().execute(ctx)).resolves.toEqual(expect.objectContaining({ - success: false, - message: expect.stringContaining('Protected deletion guard blocked cleanup') - })) - await expect(new DryRunCleanCommand().execute(ctx)).resolves.toEqual(expect.objectContaining({ - success: false, - message: expect.stringContaining('Protected deletion guard blocked cleanup') - })) - }) - - it('returns failure before writes run when execute pre-cleanup hits a protected path', async () => { - const workspaceDir = path.resolve('tmp-workspace-command') - const convertContent = vi.fn(async () => 'should-not-write') - const plugin = createMockOutputPlugin({ - delete: [{kind: 'directory', path: workspaceDir}] - }, convertContent) - const ctx = createCommandContext([plugin]) - - await expect(new ExecuteCommand().execute(ctx)).resolves.toEqual(expect.objectContaining({ - success: false, - message: expect.stringContaining('Protected deletion guard blocked cleanup') - })) - expect(convertContent).not.toHaveBeenCalled() - }) - - it('returns failure when an output path conflicts with a cleanup protect declaration', async () => { - const outputPath = path.join(path.resolve('tmp-workspace-command'), 'project-a', 'AGENTS.md') - const plugin = createMockOutputPlugin({ - protect: [{kind: 'file', path: outputPath}] - }) - const ctx = createCommandContext([plugin]) - - await expect(new CleanCommand().execute(ctx)).resolves.toEqual(expect.objectContaining({ - success: false, - message: expect.stringContaining('Cleanup protection conflict') - })) - }) - - it('reuses declared outputs across cleanup and write during execute', async () => { - const workspaceDir = path.resolve('tmp-workspace-command-cached') - const outputPath = path.join(workspaceDir, 'project-a', 'AGENTS.md') - let declareOutputFilesCalls = 0 - const plugin: OutputPlugin = { - type: PluginKind.Output, - name: 'CachedOutputPlugin', - log: createMockLogger(), - declarativeOutput: true, - outputCapabilities: {}, - async declareOutputFiles() { - declareOutputFilesCalls += 1 - return [{path: outputPath, source: {}}] - }, - async declareCleanupPaths() { - return {} - }, - async convertContent() { - return 'cached-output' - } - } - - fs.rmSync(workspaceDir, {recursive: true, force: true}) - fs.mkdirSync(path.join(workspaceDir, 'project-a'), {recursive: true}) - - try { - const ctx = createCommandContext([plugin], workspaceDir) - const result = await new ExecuteCommand().execute(ctx) - - expect(result.success).toBe(true) - expect(declareOutputFilesCalls).toBe(1) - expect(fs.readFileSync(outputPath, 'utf8')).toBe('cached-output') - } - finally { - fs.rmSync(workspaceDir, {recursive: true, force: true}) - } - }) - - it('includes structured diagnostics in JSON output errors', async () => { - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - const command = new JsonOutputCommand({ - name: 'mock', - async execute(ctx) { - ctx.logger.error({ - code: 'MOCK_FAILURE', - title: 'Mock command failed', - rootCause: ['The mock command was forced to fail for JSON output testing.'], - exactFix: ['Update the mock command inputs so it no longer emits the test failure.'] - }) - return { - success: false, - filesAffected: 0, - dirsAffected: 0, - message: 'blocked' - } - } - }) - - try { - await command.execute({ - ...createCommandContext([]), - logger: createLogger('ProtectedDeletionJsonTest', 'silent') - }) - expect(writeSpy).toHaveBeenCalledOnce() - const payload = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) as { - readonly message?: string - readonly warnings: readonly unknown[] - readonly errors: readonly {code: string, title: string, rootCause: readonly string[], copyText: readonly string[]}[] - } - - expect(payload.message).toBe('blocked') - expect(payload.warnings).toEqual([]) - expect(payload.errors).toEqual([ - expect.objectContaining({ - code: 'MOCK_FAILURE', - title: 'Mock command failed', - rootCause: ['The mock command was forced to fail for JSON output testing.'], - copyText: expect.arrayContaining(['[MOCK_FAILURE] Mock command failed']) - }) - ]) - } - finally { - writeSpy.mockRestore() - } - }) - - it('includes workspace empty directories in clean dry-run results', async () => { - const workspaceDir = path.resolve('tmp-workspace-command-dry-run-empty') - const generatedDir = path.join(workspaceDir, 'generated') - const generatedFile = path.join(generatedDir, 'AGENTS.md') - const emptyLeafDir = path.join(workspaceDir, 'scratch', 'empty', 'leaf') - const retainedScratchFile = path.join(workspaceDir, 'scratch', 'keep.md') - const plugin: OutputPlugin = { - type: PluginKind.Output, - name: 'DryRunEmptyDirPlugin', - log: createMockLogger(), - declarativeOutput: true, - outputCapabilities: {}, - async declareOutputFiles() { - return [{path: generatedFile, source: {}}] - }, - async declareCleanupPaths() { - return {} - }, - async convertContent() { - return '' - } - } - - fs.rmSync(workspaceDir, {recursive: true, force: true}) - fs.mkdirSync(generatedDir, {recursive: true}) - fs.mkdirSync(emptyLeafDir, {recursive: true}) - fs.mkdirSync(path.dirname(retainedScratchFile), {recursive: true}) - fs.writeFileSync(generatedFile, '# generated', 'utf8') - fs.writeFileSync(retainedScratchFile, '# keep', 'utf8') - - try { - const ctx = createCommandContext([plugin], workspaceDir) - const result = await new DryRunCleanCommand().execute(ctx) - - expect(result).toEqual(expect.objectContaining({ - success: true, - filesAffected: 1, - dirsAffected: 3 - })) - } - finally { - fs.rmSync(workspaceDir, {recursive: true, force: true}) - } - }) -}) diff --git a/cli/src/commands/UnknownCommand.ts b/cli/src/commands/UnknownCommand.ts index 7a530f42..c8ec4a05 100644 --- a/cli/src/commands/UnknownCommand.ts +++ b/cli/src/commands/UnknownCommand.ts @@ -1,34 +1,23 @@ import type {Command, CommandContext, CommandResult} from './Command' -import {buildUsageDiagnostic, diagnosticLines} from '@/diagnostics' +import {buildUsageDiagnostic, diagnosticLines} from '@truenine/memory-sync-sdk' -/** - * Unknown command - displays error for unrecognized subcommands - */ export class UnknownCommand implements Command { readonly name = 'unknown' - constructor(private readonly unknownCmd: string) { } + constructor(private readonly unknownCmd: string) {} async execute(ctx: CommandContext): Promise { - ctx.logger.error(buildUsageDiagnostic({ - code: 'UNKNOWN_COMMAND', - title: `Unknown tnmsc command: ${this.unknownCmd}`, - rootCause: diagnosticLines(`tnmsc does not recognize the "${this.unknownCmd}" subcommand.`), - exactFix: diagnosticLines('Run `tnmsc help` and invoke one of the supported commands.'), - possibleFixes: [ - diagnosticLines('Check the command spelling and remove unsupported aliases or flags.') - ], - details: { - command: this.unknownCmd - } - })) + ctx.logger.error( + buildUsageDiagnostic({ + code: 'UNKNOWN_COMMAND', + title: `Unknown tnmsc command: ${this.unknownCmd}`, + rootCause: diagnosticLines(`tnmsc does not recognize the "${this.unknownCmd}" subcommand.`), + exactFix: diagnosticLines('Run `tnmsc help` and invoke one of the supported commands.'), + possibleFixes: [diagnosticLines('Check the command spelling and remove unsupported aliases or flags.')], + details: {command: this.unknownCmd} + }) + ) ctx.logger.info('run "tnmsc help" for available commands') - - return { - success: false, - filesAffected: 0, - dirsAffected: 0, - message: `Unknown command: ${this.unknownCmd}` - } + return {success: false, filesAffected: 0, dirsAffected: 0, message: `Unknown command: ${this.unknownCmd}`} } } diff --git a/cli/src/commands/VersionCommand.ts b/cli/src/commands/VersionCommand.ts index 6f03525e..c49ab789 100644 --- a/cli/src/commands/VersionCommand.ts +++ b/cli/src/commands/VersionCommand.ts @@ -2,28 +2,15 @@ import type {Command, CommandContext, CommandResult} from './Command' const CLI_NAME = 'tnmsc' -/** - * Get CLI version from build-time injected constant. - * Falls back to 'unknown' in development mode. - */ export function getCliVersion(): string { return typeof __CLI_VERSION__ !== 'undefined' ? __CLI_VERSION__ : 'dev' } -/** - * Version command - displays CLI version - */ export class VersionCommand implements Command { readonly name = 'version' async execute(ctx: CommandContext): Promise { ctx.logger.info(`${CLI_NAME} v${getCliVersion()}`) - - return { - success: true, - filesAffected: 0, - dirsAffected: 0, - message: 'Version displayed' - } + return {success: true, filesAffected: 0, dirsAffected: 0, message: 'Version displayed'} } } diff --git a/cli/src/commands/bridge.rs b/cli/src/commands/bridge.rs index d3d18de0..a068e599 100644 --- a/cli/src/commands/bridge.rs +++ b/cli/src/commands/bridge.rs @@ -1,23 +1,21 @@ use std::process::ExitCode; -use crate::bridge::node::run_node_command; - pub fn execute(json_mode: bool) -> ExitCode { - run_node_command("execute", json_mode, &[]) + tnmsc::bridge::node::run_node_command("execute", json_mode, &[]) } pub fn dry_run(json_mode: bool) -> ExitCode { - run_node_command("dry-run", json_mode, &[]) + tnmsc::bridge::node::run_node_command("dry-run", json_mode, &[]) } pub fn clean(json_mode: bool) -> ExitCode { - run_node_command("clean", json_mode, &[]) + tnmsc::bridge::node::run_node_command("clean", json_mode, &[]) } pub fn dry_run_clean(json_mode: bool) -> ExitCode { - run_node_command("clean", json_mode, &["--dry-run"]) + tnmsc::bridge::node::run_node_command("clean", json_mode, &["--dry-run"]) } pub fn plugins(json_mode: bool) -> ExitCode { - run_node_command("plugins", json_mode, &[]) + tnmsc::bridge::node::run_node_command("plugins", json_mode, &[]) } diff --git a/cli/src/commands/config_cmd.rs b/cli/src/commands/config_cmd.rs index e7eb62b5..5ac94898 100644 --- a/cli/src/commands/config_cmd.rs +++ b/cli/src/commands/config_cmd.rs @@ -1,107 +1,32 @@ +use std::path::Path; use std::process::ExitCode; -use crate::diagnostic_helpers::{diagnostic, line, optional_details}; -use serde_json::json; use tnmsc_logger::create_logger; -use crate::core::config::{ConfigLoader, get_required_global_config_path}; - pub fn execute(pairs: &[(String, String)]) -> ExitCode { let logger = create_logger("config", None); - let result = match ConfigLoader::with_defaults().try_load(std::path::Path::new(".")) { - Ok(result) => result, - Err(error) => { - logger.error(diagnostic( - "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED", - "Failed to resolve the global config path", - line("The runtime could not determine which global config file should be updated."), - Some(line( - "Ensure the required global config exists and retry the command.", - )), - None, - optional_details(json!({ "error": error })), - )); - return ExitCode::FAILURE; - } - }; - let mut config = result.config; - for (key, value) in pairs { - match key.as_str() { - "workspaceDir" => config.workspace_dir = Some(value.clone()), - "logLevel" => config.log_level = Some(value.clone()), - _ => { - logger.warn(diagnostic( - "CONFIG_KEY_UNKNOWN", - "Unknown config key was ignored", - line("The provided config key is not supported by this command."), - Some(line( - "Use one of the supported keys: `workspaceDir`, `logLevel`.", - )), - None, - optional_details(json!({ "key": key })), - )); - } + for (key, _) in pairs { + if key != "workspaceDir" && key != "logLevel" { + logger.info( + format!( + "Unknown config key was ignored: {key}. Supported keys: workspaceDir, logLevel" + ), + None, + ); } } - let config_path = match get_required_global_config_path() { - Ok(path) => path, - Err(error) => { - logger.error(diagnostic( - "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED", - "Failed to resolve the global config path", - line("The runtime could not determine which global config file should be written."), - Some(line( - "Ensure the required global config exists and retry the command.", - )), + match tnmsc::update_global_config_from_pairs(Path::new("."), pairs) { + Ok(config_path) => { + logger.info( + serde_json::Value::String(format!("Config saved to {}", config_path.display())), None, - optional_details(json!({ "error": error })), - )); - return ExitCode::FAILURE; - } - }; - match serde_json::to_string_pretty(&config) { - Ok(json) => { - if let Some(parent) = config_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - match std::fs::write(&config_path, &json) { - Ok(()) => { - logger.info( - serde_json::Value::String(format!( - "Config saved to {}", - config_path.display() - )), - None, - ); - ExitCode::SUCCESS - } - Err(e) => { - logger.error(diagnostic( - "CONFIG_WRITE_FAILED", - "Failed to write the global config file", - line("The CLI generated the config JSON but could not write it to disk."), - Some(line("Check that the config path is writable and retry.")), - None, - optional_details(json!({ - "path": config_path.to_string_lossy(), - "error": e.to_string() - })), - )); - ExitCode::FAILURE - } - } + ); + ExitCode::SUCCESS } - Err(e) => { - logger.error(diagnostic( - "CONFIG_SERIALIZATION_FAILED", - "Failed to serialize the config", - line("The config object could not be converted to JSON."), - None, - None, - optional_details(json!({ "error": e.to_string() })), - )); + Err(error) => { + eprintln!("{error}"); ExitCode::FAILURE } } diff --git a/cli/src/commands/config_show.rs b/cli/src/commands/config_show.rs index 0c9be861..4525933e 100644 --- a/cli/src/commands/config_show.rs +++ b/cli/src/commands/config_show.rs @@ -1,43 +1,14 @@ +use std::path::Path; use std::process::ExitCode; -use crate::diagnostic_helpers::{diagnostic, line, optional_details}; -use serde_json::json; -use tnmsc_logger::create_logger; - -use crate::core::config::ConfigLoader; - pub fn execute() -> ExitCode { - let logger = create_logger("config-show", None); - let result = match ConfigLoader::with_defaults().try_load(std::path::Path::new(".")) { - Ok(result) => result, - Err(error) => { - logger.error(diagnostic( - "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED", - "Failed to resolve the global config path", - line("The runtime could not determine which global config file should be shown."), - Some(line( - "Ensure the required global config exists and retry the command.", - )), - None, - optional_details(json!({ "error": error })), - )); - return ExitCode::FAILURE; - } - }; - match serde_json::to_string_pretty(&result.config) { + match tnmsc::config_show(Path::new(".")) { Ok(json) => { println!("{json}"); ExitCode::SUCCESS } - Err(e) => { - logger.error(diagnostic( - "CONFIG_SERIALIZATION_FAILED", - "Failed to serialize the config", - line("The merged config could not be converted to JSON for display."), - None, - None, - optional_details(json!({ "error": e.to_string() })), - )); + Err(error) => { + eprintln!("{error}"); ExitCode::FAILURE } } diff --git a/cli/src/commands/factories/CleanCommandFactory.ts b/cli/src/commands/factories/CleanCommandFactory.ts index 017d1025..3e92a178 100644 --- a/cli/src/commands/factories/CleanCommandFactory.ts +++ b/cli/src/commands/factories/CleanCommandFactory.ts @@ -4,17 +4,12 @@ import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' import {CleanCommand} from '../CleanCommand' import {DryRunCleanCommand} from '../DryRunCleanCommand' -/** - * Factory for creating CleanCommand or DryRunCleanCommand - * Handles 'clean' subcommand with optional --dry-run flag - */ export class CleanCommandFactory implements CommandFactory { canHandle(args: ParsedCliArgs): boolean { return args.subcommand === 'clean' } createCommand(args: ParsedCliArgs): Command { - if (args.dryRun) return new DryRunCleanCommand() - return new CleanCommand() + return args.dryRun ? new DryRunCleanCommand() : new CleanCommand() } } diff --git a/cli/src/commands/factories/ConfigCommandFactory.ts b/cli/src/commands/factories/ConfigCommandFactory.ts index bc7b6fe0..005ea10f 100644 --- a/cli/src/commands/factories/ConfigCommandFactory.ts +++ b/cli/src/commands/factories/ConfigCommandFactory.ts @@ -4,26 +4,19 @@ import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' import {ConfigCommand} from '../ConfigCommand' import {ConfigShowCommand} from '../ConfigShowCommand' -/** - * Factory for creating ConfigCommand or ConfigShowCommand - * Handles 'config' subcommand with --show flag or key=value arguments - */ export class ConfigCommandFactory implements CommandFactory { canHandle(args: ParsedCliArgs): boolean { return args.subcommand === 'config' } createCommand(args: ParsedCliArgs): Command { - if (args.showFlag) { // Config --show subcommand - return new ConfigShowCommand() - } + if (args.showFlag) return new ConfigShowCommand() - const parsedPositional: [key: string, value: string][] = [] // Parse positional arguments as key=value pairs + const parsedPositional: [key: string, value: string][] = [] for (const arg of args.positional) { const eqIndex = arg.indexOf('=') if (eqIndex > 0) parsedPositional.push([arg.slice(0, eqIndex), arg.slice(eqIndex + 1)]) } - return new ConfigCommand([...args.setOption, ...parsedPositional]) } } diff --git a/cli/src/commands/factories/DryRunCommandFactory.ts b/cli/src/commands/factories/DryRunCommandFactory.ts index 232901ea..cefc3b6f 100644 --- a/cli/src/commands/factories/DryRunCommandFactory.ts +++ b/cli/src/commands/factories/DryRunCommandFactory.ts @@ -3,17 +3,12 @@ import type {CommandFactory} from '../CommandFactory' import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' import {DryRunOutputCommand} from '../DryRunOutputCommand' -/** - * Factory for creating DryRunOutputCommand - * Handles 'dry-run' subcommand - */ export class DryRunCommandFactory implements CommandFactory { canHandle(args: ParsedCliArgs): boolean { return args.subcommand === 'dry-run' } - createCommand(args: ParsedCliArgs): Command { - void args + createCommand(): Command { return new DryRunOutputCommand() } } diff --git a/cli/src/commands/factories/ExecuteCommandFactory.ts b/cli/src/commands/factories/ExecuteCommandFactory.ts index d7a6f8dc..681b3447 100644 --- a/cli/src/commands/factories/ExecuteCommandFactory.ts +++ b/cli/src/commands/factories/ExecuteCommandFactory.ts @@ -1,20 +1,13 @@ import type {Command} from '../Command' import type {CommandFactory} from '../CommandFactory' -import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' import {ExecuteCommand} from '../ExecuteCommand' -/** - * Factory for creating ExecuteCommand (default command) - * Handles default execution when no specific subcommand matches - */ export class ExecuteCommandFactory implements CommandFactory { - canHandle(args: ParsedCliArgs): boolean { // This is a catch-all factory with lowest priority - void args + canHandle(): boolean { return true } - createCommand(args: ParsedCliArgs): Command { - void args + createCommand(): Command { return new ExecuteCommand() } } diff --git a/cli/src/commands/factories/HelpCommandFactory.ts b/cli/src/commands/factories/HelpCommandFactory.ts index 3b4174a5..7db10b96 100644 --- a/cli/src/commands/factories/HelpCommandFactory.ts +++ b/cli/src/commands/factories/HelpCommandFactory.ts @@ -4,10 +4,6 @@ import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' import {FactoryPriority} from '../CommandFactory' import {HelpCommand} from '../HelpCommand' -/** - * Factory for creating HelpCommand - * Handles --help flag and 'help' subcommand - */ export class HelpCommandFactory implements PrioritizedCommandFactory { readonly priority = FactoryPriority.Flags @@ -15,8 +11,7 @@ export class HelpCommandFactory implements PrioritizedCommandFactory { return args.helpFlag || args.subcommand === 'help' } - createCommand(args: ParsedCliArgs): Command { - void args + createCommand(): Command { return new HelpCommand() } } diff --git a/cli/src/commands/factories/InitCommandFactory.ts b/cli/src/commands/factories/InitCommandFactory.ts index 71f55fca..afe09f8e 100644 --- a/cli/src/commands/factories/InitCommandFactory.ts +++ b/cli/src/commands/factories/InitCommandFactory.ts @@ -8,8 +8,7 @@ export class InitCommandFactory implements CommandFactory { return args.subcommand === 'init' } - createCommand(args: ParsedCliArgs): Command { - void args + createCommand(): Command { return new InitCommand() } } diff --git a/cli/src/commands/factories/PluginsCommandFactory.ts b/cli/src/commands/factories/PluginsCommandFactory.ts index 11b25ecb..2d3f87d3 100644 --- a/cli/src/commands/factories/PluginsCommandFactory.ts +++ b/cli/src/commands/factories/PluginsCommandFactory.ts @@ -3,17 +3,12 @@ import type {CommandFactory} from '../CommandFactory' import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' import {PluginsCommand} from '../PluginsCommand' -/** - * Factory for creating PluginsCommand - * Handles 'plugins' subcommand - */ export class PluginsCommandFactory implements CommandFactory { canHandle(args: ParsedCliArgs): boolean { return args.subcommand === 'plugins' } - createCommand(args: ParsedCliArgs): Command { - void args + createCommand(): Command { return new PluginsCommand() } } diff --git a/cli/src/commands/factories/UnknownCommandFactory.ts b/cli/src/commands/factories/UnknownCommandFactory.ts index 6c97fb62..bea8f387 100644 --- a/cli/src/commands/factories/UnknownCommandFactory.ts +++ b/cli/src/commands/factories/UnknownCommandFactory.ts @@ -4,10 +4,6 @@ import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' import {FactoryPriority} from '../CommandFactory' import {UnknownCommand} from '../UnknownCommand' -/** - * Factory for creating UnknownCommand - * Handles unknown/invalid subcommands - */ export class UnknownCommandFactory implements PrioritizedCommandFactory { readonly priority = FactoryPriority.Unknown @@ -16,7 +12,6 @@ export class UnknownCommandFactory implements PrioritizedCommandFactory { } createCommand(args: ParsedCliArgs): Command { - if (args.unknownCommand == null) return new UnknownCommand('') - return new UnknownCommand(args.unknownCommand) + return new UnknownCommand(args.unknownCommand ?? '') } } diff --git a/cli/src/commands/factories/VersionCommandFactory.ts b/cli/src/commands/factories/VersionCommandFactory.ts index 95dbc123..f0deb6d1 100644 --- a/cli/src/commands/factories/VersionCommandFactory.ts +++ b/cli/src/commands/factories/VersionCommandFactory.ts @@ -4,10 +4,6 @@ import type {ParsedCliArgs} from '@/pipeline/CliArgumentParser' import {FactoryPriority} from '../CommandFactory' import {VersionCommand} from '../VersionCommand' -/** - * Factory for creating VersionCommand - * Handles --version flag and 'version' subcommand - */ export class VersionCommandFactory implements PrioritizedCommandFactory { readonly priority = FactoryPriority.Flags @@ -15,8 +11,7 @@ export class VersionCommandFactory implements PrioritizedCommandFactory { return args.versionFlag || args.subcommand === 'version' } - createCommand(args: ParsedCliArgs): Command { - void args + createCommand(): Command { return new VersionCommand() } } diff --git a/cli/src/commands/version.rs b/cli/src/commands/version.rs index 8321606a..b0cf860b 100644 --- a/cli/src/commands/version.rs +++ b/cli/src/commands/version.rs @@ -1,6 +1,6 @@ use std::process::ExitCode; pub fn execute() -> ExitCode { - println!("{}", env!("CARGO_PKG_VERSION")); + println!("{}", tnmsc::version()); ExitCode::SUCCESS } diff --git a/cli/src/globals.ts b/cli/src/globals.ts index 4622248d..7b218b19 100644 --- a/cli/src/globals.ts +++ b/cli/src/globals.ts @@ -1 +1 @@ -export * from '@truenine/md-compiler/globals' +export * from '@truenine/memory-sync-sdk/globals' diff --git a/cli/src/index.test.ts b/cli/src/index.test.ts index 0727ccea..9966cb7d 100644 --- a/cli/src/index.test.ts +++ b/cli/src/index.test.ts @@ -1,11 +1,12 @@ -import {describe, expect, it} from 'vitest' +import {listPrompts} from '@truenine/memory-sync-sdk' -describe('library entrypoint', () => { - it('can be imported without executing the CLI runtime', async () => { - const mod = await import('./index') +import {describe, expect, it} from 'vitest' +import * as cliShell from './index' - expect(typeof mod.runCli).toBe('function') - expect(typeof mod.createDefaultPluginConfig).toBe('function') - expect(typeof mod.listPrompts).toBe('function') +describe('cli shell entrypoint', () => { + it('re-exports sdk library APIs while keeping local shell exports', async () => { + expect(typeof cliShell.runCli).toBe('function') + expect(typeof cliShell.createDefaultPluginConfig).toBe('function') + expect(cliShell.listPrompts).toBe(listPrompts) }) }) diff --git a/cli/src/index.ts b/cli/src/index.ts index dffa366e..a99ce905 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -5,20 +5,9 @@ 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' -export * from './ConfigLoader' -export { - createDefaultPluginConfig -} from './plugin.config' -export * from './PluginPipeline' -export { - DEFAULT_USER_CONFIG, - PathPlaceholders -} from './plugins/plugin-core' - -export * from './prompts' +export * from './plugin.config' +export * from '@truenine/memory-sync-sdk' function isCliEntrypoint(argv: readonly string[] = process.argv): boolean { const entryPath = argv[1] @@ -26,8 +15,7 @@ function isCliEntrypoint(argv: readonly string[] = process.argv): boolean { try { return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url)) - } - catch { + } catch { return false } } diff --git a/cli/src/main.rs b/cli/src/main.rs index dc5cbf5b..0d37f665 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,26 +1,25 @@ -//! tnmsc — Rust CLI entry point. +//! tnmsc — Rust CLI shell entry point. //! //! Pure Rust commands: help, version, config, config-show //! Bridge commands (Node.js): execute, dry-run, clean, plugins mod cli; +mod commands; use std::process::ExitCode; use clap::Parser; -use tnmsc_logger::set_global_log_level; +use tnmsc_logger::{flush_output, set_global_log_level}; use cli::{Cli, ResolvedCommand, resolve_command, resolve_log_level}; fn main() -> ExitCode { let cli = Cli::parse(); - // Resolve and set global log level if let Some(level) = resolve_log_level(&cli) { set_global_log_level(level.to_logger_level()); } - // In JSON mode, suppress all log output let json_mode = cli.json; if json_mode { set_global_log_level(tnmsc_logger::LogLevel::Silent); @@ -28,18 +27,18 @@ fn main() -> ExitCode { let command = resolve_command(&cli); - match command { - // Pure Rust commands - ResolvedCommand::Help => tnmsc::commands::help::execute(), - ResolvedCommand::Version => tnmsc::commands::version::execute(), - ResolvedCommand::Config(pairs) => tnmsc::commands::config_cmd::execute(&pairs), - ResolvedCommand::ConfigShow => tnmsc::commands::config_show::execute(), - - // Bridge commands (delegate to Node.js plugin runtime) - ResolvedCommand::Execute => tnmsc::commands::bridge::execute(json_mode), - ResolvedCommand::DryRun => tnmsc::commands::bridge::dry_run(json_mode), - ResolvedCommand::Clean => tnmsc::commands::bridge::clean(json_mode), - ResolvedCommand::DryRunClean => tnmsc::commands::bridge::dry_run_clean(json_mode), - ResolvedCommand::Plugins => tnmsc::commands::bridge::plugins(json_mode), - } + let exit_code = match command { + ResolvedCommand::Help => commands::help::execute(), + ResolvedCommand::Version => commands::version::execute(), + ResolvedCommand::Config(pairs) => commands::config_cmd::execute(&pairs), + ResolvedCommand::ConfigShow => commands::config_show::execute(), + ResolvedCommand::Execute => commands::bridge::execute(json_mode), + ResolvedCommand::DryRun => commands::bridge::dry_run(json_mode), + ResolvedCommand::Clean => commands::bridge::clean(json_mode), + ResolvedCommand::DryRunClean => commands::bridge::dry_run_clean(json_mode), + ResolvedCommand::Plugins => commands::bridge::plugins(json_mode), + }; + + flush_output(); + exit_code } diff --git a/cli/src/pipeline/CliArgumentParser.ts b/cli/src/pipeline/CliArgumentParser.ts index ac5c1b60..0ead16dd 100644 --- a/cli/src/pipeline/CliArgumentParser.ts +++ b/cli/src/pipeline/CliArgumentParser.ts @@ -1,10 +1,3 @@ -/** - * CLI Argument Parser Module - * Handles extraction and parsing of command-line arguments - * - * Refactored to use Command Factory pattern for command creation - */ - import type {Command} from '@/commands/Command' import {FactoryPriority} from '@/commands/CommandFactory' import {CommandRegistry} from '@/commands/CommandRegistry' @@ -18,19 +11,9 @@ import {PluginsCommandFactory} from '@/commands/factories/PluginsCommandFactory' import {UnknownCommandFactory} from '@/commands/factories/UnknownCommandFactory' import {VersionCommandFactory} from '@/commands/factories/VersionCommandFactory' -/** - * Valid subcommands for the CLI - */ export type Subcommand = 'help' | 'version' | 'init' | 'dry-run' | 'clean' | 'config' | 'plugins' - -/** - * Valid log levels for the CLI - */ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' -/** - * Command line argument parsing result - */ export interface ParsedCliArgs { readonly subcommand: Subcommand | undefined readonly helpFlag: boolean @@ -45,14 +28,7 @@ export interface ParsedCliArgs { readonly unknown: readonly string[] } -/** - * Valid subcommands set for quick lookup - */ const VALID_SUBCOMMANDS: ReadonlySet = new Set(['help', 'version', 'init', 'dry-run', 'clean', 'config', 'plugins']) - -/** - * Log level flags mapping - */ const LOG_LEVEL_FLAGS: ReadonlyMap = new Map([ ['--trace', 'trace'], ['--debug', 'debug'], @@ -60,10 +36,6 @@ const LOG_LEVEL_FLAGS: ReadonlyMap = new Map([ ['--warn', 'warn'], ['--error', 'error'] ]) - -/** - * Log level priority map (lower number = more verbose) - */ const LOG_LEVEL_PRIORITY: ReadonlyMap = new Map([ ['trace', 0], ['debug', 1], @@ -72,41 +44,25 @@ const LOG_LEVEL_PRIORITY: ReadonlyMap = new Map([ ['error', 4] ]) -/** - * Extract actual user arguments from argv - * Compatible with various execution scenarios: npx, node, tsx, direct execution, etc. - */ export function extractUserArgs(argv: readonly string[]): string[] { const args = [...argv] - - const first = args[0] // Skip runtime path (node, bun, deno, etc.) + const first = args[0] if (first != null && isRuntimeExecutable(first)) args.shift() - - const second = args[0] // Skip script path or npx package name + const second = args[0] if (second != null && isScriptOrPackage(second)) args.shift() - return args } -/** - * Determine if it is a runtime executable - */ function isRuntimeExecutable(arg: string): boolean { const runtimes = ['node', 'nodejs', 'bun', 'deno', 'tsx', 'ts-node', 'npx', 'pnpx', 'yarn', 'pnpm'] const normalized = arg.toLowerCase().replaceAll('\\', '/') - return runtimes.some(rt => { - const pattern = new RegExp(`(?:^|/)${rt}(?:\\.exe|\\.cmd|\\.ps1)?$`, 'i') - return pattern.test(normalized) || normalized === rt - }) + return runtimes.some(runtime => new RegExp(`(?:^|/)${runtime}(?:\\.exe|\\.cmd|\\.ps1)?$`, 'i').test(normalized) || normalized === runtime) } -/** - * Determine if it is a script file or package name - */ function isScriptOrPackage(arg: string): boolean { - if (/\.(?:m?[jt]s|cjs)$/.test(arg)) return true // Script file - if (/[/\\]/.test(arg) && !arg.startsWith('-')) return true // File path containing separators - return /^(?:@[\w-]+\/)?[\w-]+$/.test(arg) && !arg.startsWith('-') // npx executed package name + if (/\.(?:m?[jt]s|cjs)$/u.test(arg)) return true + if (/[/\\]/u.test(arg) && !arg.startsWith('-')) return true + return /^(?:@[\w-]+\/)?[\w-]+$/u.test(arg) && !arg.startsWith('-') } function pickMoreVerbose(current: LogLevel | undefined, candidate: LogLevel): LogLevel { @@ -116,9 +72,6 @@ function pickMoreVerbose(current: LogLevel | undefined, candidate: LogLevel): Lo return candidatePriority < currentPriority ? candidate : current } -/** - * Parse command line arguments into structured result - */ export function parseArgs(args: readonly string[]): ParsedCliArgs { const result: { subcommand: Subcommand | undefined @@ -147,119 +100,118 @@ export function parseArgs(args: readonly string[]): ParsedCliArgs { } let firstPositionalProcessed = false - for (let i = 0; i < args.length; i++) { const arg = args[i] if (arg == null) continue - - if (arg === '--') { // Handle -- separator: all following args are positional - result.positional.push(...args.slice(i + 1).filter((a): a is string => a != null)) + if (arg === '--') { + result.positional.push(...args.slice(i + 1).filter((value): value is string => value != null)) break } - if (arg.startsWith('--')) { // Long options + if (arg.startsWith('--')) { const parts = arg.split('=') const key = parts[0] ?? '' - - const logLevel = LOG_LEVEL_FLAGS.get(key) // Check log level flags + const logLevel = LOG_LEVEL_FLAGS.get(key) if (logLevel != null) { result.logLevel = pickMoreVerbose(result.logLevel, logLevel) continue } switch (key) { - case '--help': result.helpFlag = true; break - case '--version': result.versionFlag = true; break - case '--dry-run': result.dryRun = true; break - case '--json': result.jsonFlag = true; break - case '--show': result.showFlag = true; break - case '--set': - if (parts.length > 1) { // Parse --set key=value from next arg or from = syntax + case '--help': + result.helpFlag = true + break + case '--version': + result.versionFlag = true + break + case '--dry-run': + result.dryRun = true + break + case '--json': + result.jsonFlag = true + break + case '--show': + result.showFlag = true + break + case '--set': { + if (parts.length > 1) { const keyValue = parts.slice(1).join('=') const eqIndex = keyValue.indexOf('=') if (eqIndex > 0) result.setOption.push([keyValue.slice(0, eqIndex), keyValue.slice(eqIndex + 1)]) } else { - const nextArg = args[i + 1] // Next arg is the value + const nextArg = args[i + 1] if (nextArg != null) { const eqIndex = nextArg.indexOf('=') if (eqIndex > 0) { result.setOption.push([nextArg.slice(0, eqIndex), nextArg.slice(eqIndex + 1)]) - i++ // Skip next arg + i++ } } } break - default: result.unknown.push(arg) + } + default: + result.unknown.push(arg) } continue } - if (arg.startsWith('-') && arg.length > 1) { // Short options - const flags = arg.slice(1) - for (const flag of flags) { + if (arg.startsWith('-') && arg.length > 1) { + for (const flag of arg.slice(1)) { switch (flag) { - case 'h': result.helpFlag = true; break - case 'v': result.versionFlag = true; break - case 'n': result.dryRun = true; break - case 'j': result.jsonFlag = true; break - default: result.unknown.push(`-${flag}`) + case 'h': + result.helpFlag = true + break + case 'v': + result.versionFlag = true + break + case 'n': + result.dryRun = true + break + case 'j': + result.jsonFlag = true + break + default: + result.unknown.push(`-${flag}`) } } continue } - if (!firstPositionalProcessed) { // First positional argument: check if it's a subcommand + if (!firstPositionalProcessed) { firstPositionalProcessed = true if (VALID_SUBCOMMANDS.has(arg)) result.subcommand = arg as Subcommand - else { - result.unknownCommand = arg // Unknown first positional is captured as unknownCommand - } + else result.unknownCommand = arg continue } - result.positional.push(arg) // Remaining positional arguments + result.positional.push(arg) } return result } -/** - * Singleton instance of the command registry - * Lazy-loaded to ensure factories are only created when needed - */ -let commandRegistry: ReturnType | undefined +let commandRegistry: CommandRegistry | undefined function createDefaultCommandRegistry(): CommandRegistry { const registry = new CommandRegistry() - - registry.register(new VersionCommandFactory()) // High priority: flag-based commands + registry.register(new VersionCommandFactory()) registry.register(new HelpCommandFactory()) registry.register(new UnknownCommandFactory()) - registry.registerWithPriority(new InitCommandFactory(), FactoryPriority.Subcommand) registry.registerWithPriority(new DryRunCommandFactory(), FactoryPriority.Subcommand) registry.registerWithPriority(new CleanCommandFactory(), FactoryPriority.Subcommand) registry.registerWithPriority(new PluginsCommandFactory(), FactoryPriority.Subcommand) registry.registerWithPriority(new ConfigCommandFactory(), FactoryPriority.Subcommand) - - registry.registerWithPriority(new ExecuteCommandFactory(), FactoryPriority.Subcommand) // Lowest priority: default/catch-all command - + registry.registerWithPriority(new ExecuteCommandFactory(), FactoryPriority.Subcommand) return registry } -/** - * Get or create the command registry singleton - */ -function getCommandRegistry(): ReturnType { +function getCommandRegistry(): CommandRegistry { commandRegistry ??= createDefaultCommandRegistry() return commandRegistry } -/** - * Resolve command from parsed CLI arguments using factory pattern - * Delegates command creation to registered factories based on priority - */ export function resolveCommand(args: ParsedCliArgs): Command { - const registry = getCommandRegistry() - return registry.resolve(args) + return getCommandRegistry().resolve(args) } diff --git a/cli/src/plugin-runtime.ts b/cli/src/plugin-runtime.ts index c23b0cf8..dfc0f0f7 100644 --- a/cli/src/plugin-runtime.ts +++ b/cli/src/plugin-runtime.ts @@ -1,79 +1,84 @@ -import type {OutputCleanContext, OutputWriteContext} from './plugins/plugin-core' -/** - * Plugin Runtime Entry Point - * - * Streamlined entry for the Rust CLI binary to spawn via Node.js. - * Accepts a subcommand and flags, executes the plugin pipeline, - * and outputs results to stdout. - * - * Usage: node plugin-runtime.mjs [--json] [--dry-run] - * - * Subcommands: execute, dry-run, clean, plugins - */ +import type {OutputCleanContext, OutputWriteContext, RuntimeCommand} from '@truenine/memory-sync-sdk' import type {Command, CommandContext} from '@/commands/Command' -import type {PipelineConfig} from '@/config' import process from 'node:process' +import { + buildUnhandledExceptionDiagnostic, + createLogger, + discoverOutputRuntimeTargets, + drainBufferedDiagnostics, + flushOutput, + setGlobalLogLevel +} from '@truenine/memory-sync-sdk' import {CleanCommand} from '@/commands/CleanCommand' import {DryRunCleanCommand} from '@/commands/DryRunCleanCommand' import {DryRunOutputCommand} from '@/commands/DryRunOutputCommand' import {ExecuteCommand} from '@/commands/ExecuteCommand' import {JsonOutputCommand, toJsonCommandResult} from '@/commands/JsonOutputCommand' import {PluginsCommand} from '@/commands/PluginsCommand' -import {buildUnhandledExceptionDiagnostic} from '@/diagnostics' -import {discoverOutputRuntimeTargets} from '@/pipeline/OutputRuntimeTargets' import {createDefaultPluginConfig} from './plugin.config' -import {createLogger, drainBufferedDiagnostics, setGlobalLogLevel} from './plugins/plugin-core' -/** - * Parse runtime arguments. - * Expected: node plugin-runtime.mjs [--json] [--dry-run] - */ -function parseRuntimeArgs(argv: string[]): {subcommand: string, json: boolean, dryRun: boolean} { - const args = argv.slice(2) // Skip node and script path - let subcommand = 'execute' +function parseRuntimeArgs(argv: string[]): {subcommand: RuntimeCommand, json: boolean, dryRun: boolean} { + const args = argv.slice(2) + let subcommand: RuntimeCommand = 'execute' let json = false let dryRun = false - for (const arg of args) { if (arg === '--json' || arg === '-j') json = true else if (arg === '--dry-run' || arg === '-n') dryRun = true - else if (!arg.startsWith('-')) subcommand = arg + else if (!arg.startsWith('-')) { + subcommand = arg === 'plugins' || arg === 'clean' || arg === 'dry-run' ? arg : 'execute' + } } - return {subcommand, json, dryRun} } -/** - * Resolve command from subcommand string. - */ -function resolveRuntimeCommand(subcommand: string, dryRun: boolean): Command { +function resolveRuntimeCommand(subcommand: RuntimeCommand, dryRun: boolean): Command { switch (subcommand) { - case 'execute': return new ExecuteCommand() - case 'dry-run': return new DryRunOutputCommand() - case 'clean': return dryRun ? new DryRunCleanCommand() : new CleanCommand() - case 'plugins': return new PluginsCommand() - default: return new ExecuteCommand() + case 'execute': + return new ExecuteCommand() + case 'dry-run': + return new DryRunOutputCommand() + case 'clean': + return dryRun ? new DryRunCleanCommand() : new CleanCommand() + case 'plugins': + return new PluginsCommand() } } +function writeJsonFailure(error: unknown): void { + const logger = createLogger('plugin-runtime', 'silent') + logger.error(buildUnhandledExceptionDiagnostic('plugin-runtime', error)) + process.stdout.write( + `${JSON.stringify( + toJsonCommandResult( + { + success: false, + filesAffected: 0, + dirsAffected: 0, + message: error instanceof Error ? error.message : String(error) + }, + drainBufferedDiagnostics() + ) + )}\n` + ) +} + +function flushAndExit(code: number): never { + flushOutput() + process.exit(code) +} + async function main(): Promise { const {subcommand, json, dryRun} = parseRuntimeArgs(process.argv) - if (json) setGlobalLogLevel('silent') - const userPluginConfig: PipelineConfig = await createDefaultPluginConfig(process.argv) - + const userPluginConfig = await createDefaultPluginConfig(process.argv, subcommand) let command = resolveRuntimeCommand(subcommand, dryRun) - - if (json) { - const selfJsonCommands = new Set(['plugins']) - if (!selfJsonCommands.has(command.name)) command = new JsonOutputCommand(command) - } + if (json && !new Set(['plugins']).has(command.name)) command = new JsonOutputCommand(command) const {context, outputPlugins, userConfigOptions} = userPluginConfig const logger = createLogger('PluginRuntime') const runtimeTargets = discoverOutputRuntimeTargets(logger) - const createCleanContext = (dry: boolean): OutputCleanContext => ({ logger, collectedOutputContext: context, @@ -81,7 +86,6 @@ async function main(): Promise { runtimeTargets, dryRun: dry }) - const createWriteContext = (dry: boolean): OutputWriteContext => ({ logger, collectedOutputContext: context, @@ -90,7 +94,6 @@ async function main(): Promise { dryRun: dry, registeredPluginNames: Array.from(outputPlugins, plugin => plugin.name) }) - const commandCtx: CommandContext = { logger, outputPlugins: [...outputPlugins], @@ -99,30 +102,18 @@ async function main(): Promise { createCleanContext, createWriteContext } - const result = await command.execute(commandCtx) - if (!result.success) process.exit(1) + if (!result.success) flushAndExit(1) + flushOutput() } -function writeJsonFailure(error: unknown): void { - const errorMessage = error instanceof Error ? error.message : String(error) - const logger = createLogger('plugin-runtime', 'silent') - logger.error(buildUnhandledExceptionDiagnostic('plugin-runtime', error)) - process.stdout.write(`${JSON.stringify(toJsonCommandResult({ - success: false, - filesAffected: 0, - dirsAffected: 0, - message: errorMessage - }, drainBufferedDiagnostics()))}\n`) -} - -main().catch((e: unknown) => { +main().catch(error => { const {json} = parseRuntimeArgs(process.argv) if (json) { - writeJsonFailure(e) - process.exit(1) + writeJsonFailure(error) + flushAndExit(1) } const logger = createLogger('plugin-runtime', 'error') - logger.error(buildUnhandledExceptionDiagnostic('plugin-runtime', e)) - process.exit(1) + logger.error(buildUnhandledExceptionDiagnostic('plugin-runtime', error)) + flushAndExit(1) }) diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index 8d0dd887..7820f771 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -1,32 +1,45 @@ -import type {PipelineConfig} from '@/config' +import type {PipelineConfig, RuntimeCommand} from '@truenine/memory-sync-sdk' import process from 'node:process' -import {GenericSkillsOutputPlugin} from '@truenine/plugin-agentskills-compact' -import {AgentsOutputPlugin} from '@truenine/plugin-agentsmd' -import {ClaudeCodeCLIOutputPlugin} from '@truenine/plugin-claude-code-cli' -import {CursorOutputPlugin} from '@truenine/plugin-cursor' -import {DroidCLIOutputPlugin} from '@truenine/plugin-droid-cli' -import {EditorConfigOutputPlugin} from '@truenine/plugin-editorconfig' -import {GeminiCLIOutputPlugin} from '@truenine/plugin-gemini-cli' -import {GitExcludeOutputPlugin} from '@truenine/plugin-git-exclude' -import {JetBrainsAIAssistantCodexOutputPlugin} from '@truenine/plugin-jetbrains-ai-codex' -import {JetBrainsIDECodeStyleConfigOutputPlugin} from '@truenine/plugin-jetbrains-codestyle' -import {CodexCLIOutputPlugin} from '@truenine/plugin-openai-codex-cli' -import {OpencodeCLIOutputPlugin} from '@truenine/plugin-opencode-cli' -import {QoderIDEPluginOutputPlugin} from '@truenine/plugin-qoder-ide' -import {ReadmeMdConfigFileOutputPlugin} from '@truenine/plugin-readme' -import {TraeIDEOutputPlugin} from '@truenine/plugin-trae-ide' -import {VisualStudioCodeIDEConfigOutputPlugin} from '@truenine/plugin-vscode' -import {WarpIDEOutputPlugin} from '@truenine/plugin-warp-ide' -import {WindsurfOutputPlugin} from '@truenine/plugin-windsurf' -import {ZedIDEConfigOutputPlugin} from '@truenine/plugin-zed' -import {defineConfig} from '@/config' -import {TraeCNIDEOutputPlugin} from '@/plugins/plugin-trae-cn-ide' +import { + AgentsOutputPlugin, + ClaudeCodeCLIOutputPlugin, + CodexCLIOutputPlugin, + CursorOutputPlugin, + defineConfig, + DroidCLIOutputPlugin, + EditorConfigOutputPlugin, + GeminiCLIOutputPlugin, + GenericSkillsOutputPlugin, + GitExcludeOutputPlugin, + JetBrainsAIAssistantCodexOutputPlugin, + JetBrainsIDECodeStyleConfigOutputPlugin, + OpencodeCLIOutputPlugin, + QoderIDEPluginOutputPlugin, + ReadmeMdConfigFileOutputPlugin, + TraeCNIDEOutputPlugin, + TraeIDEOutputPlugin, + VisualStudioCodeIDEConfigOutputPlugin, + WarpIDEOutputPlugin, + WindsurfOutputPlugin, + ZedIDEConfigOutputPlugin +} from '@truenine/memory-sync-sdk' + +export function resolveRuntimeCommandFromArgv(argv: readonly string[] = process.argv): RuntimeCommand { + const args = argv.filter((arg): arg is string => arg != null) + const userArgs = args.slice(2) + const subcommand = userArgs.find(arg => !arg.startsWith('-')) + if (subcommand === 'plugins') return 'plugins' + if (subcommand === 'clean') return 'clean' + if (subcommand === 'dry-run' || userArgs.includes('--dry-run') || userArgs.includes('-n')) return 'dry-run' + return 'execute' +} export async function createDefaultPluginConfig( - pipelineArgs: readonly string[] = process.argv + argv: readonly string[] = process.argv, + runtimeCommand: RuntimeCommand = resolveRuntimeCommandFromArgv(argv) ): Promise { return defineConfig({ - pipelineArgs, + runtimeCommand, pluginOptions: { plugins: [ new AgentsOutputPlugin(), @@ -44,7 +57,6 @@ export async function createDefaultPluginConfig( new WindsurfOutputPlugin(), new CursorOutputPlugin(), new GitExcludeOutputPlugin(), - new JetBrainsIDECodeStyleConfigOutputPlugin(), new EditorConfigOutputPlugin(), new VisualStudioCodeIDEConfigOutputPlugin(), diff --git a/cli/src/script-runtime-worker.ts b/cli/src/script-runtime-worker.ts index ae6854a6..d29cfaed 100644 --- a/cli/src/script-runtime-worker.ts +++ b/cli/src/script-runtime-worker.ts @@ -4,8 +4,8 @@ import {resolvePublicPathUnchecked} from '@truenine/script-runtime' async function main(): Promise { const [, , filePath, ctxJsonPath, logicalPath] = process.argv - if (filePath == null || ctxJsonPath == null || logicalPath == null) throw new Error('Usage: script-runtime-worker ') - + if (filePath == null || ctxJsonPath == null || logicalPath == null) + { throw new Error('Usage: script-runtime-worker ') } const ctxJson = readFileSync(ctxJsonPath, 'utf8') const ctx = JSON.parse(ctxJson) as Parameters[1] const result = await resolvePublicPathUnchecked(filePath, ctx, logicalPath) @@ -13,7 +13,6 @@ async function main(): Promise { } main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error) - process.stderr.write(`${message}\n`) + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) process.exit(1) }) diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 9006c87e..6dc3fc16 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -11,7 +11,11 @@ "module": "ESNext", "moduleResolution": "Bundler", "paths": { + "@sdk": ["../sdk/src/index.ts"], + "@sdk/*": ["../sdk/src/*"], "@/*": ["./src/*"], + "@truenine/script-runtime": ["../libraries/script-runtime/dist/index.d.mts"], + "@truenine/script-runtime/*": ["../libraries/script-runtime/dist/*"], "@truenine/desk-paths": ["./src/core/desk-paths.ts"], "@truenine/desk-paths/*": ["./src/core/desk-paths/*"], "@truenine/plugin-output-shared": ["./src/plugins/plugin-output-shared/index.ts"], diff --git a/cli/tsdown.config.ts b/cli/tsdown.config.ts index 183d9c5c..7d43e4b7 100644 --- a/cli/tsdown.config.ts +++ b/cli/tsdown.config.ts @@ -1,142 +1,53 @@ -import {readFileSync} from 'node:fs' -import {resolve} from 'node:path' import {defineConfig} from 'tsdown' -const pkg = JSON.parse(readFileSync('./package.json', 'utf8')) as {version: string, name: string} -const kiroGlobalPowersRegistry = '{"version":"1.0.0","powers":{},"repoSources":{}}' - -const pluginAliases: Record = { - '@truenine/desk-paths': resolve('src/core/desk-paths.ts'), - '@truenine/plugin-output-shared': resolve('src/plugins/plugin-output-shared/index.ts'), - '@truenine/plugin-input-shared': resolve('src/plugins/plugin-input-shared/index.ts'), - '@truenine/plugin-agentskills-compact': resolve('src/plugins/plugin-agentskills-compact.ts'), - '@truenine/plugin-agentsmd': resolve('src/plugins/plugin-agentsmd.ts'), - '@truenine/plugin-antigravity': resolve('src/plugins/plugin-antigravity/index.ts'), - '@truenine/plugin-claude-code-cli': resolve('src/plugins/plugin-claude-code-cli.ts'), - '@truenine/plugin-cursor': resolve('src/plugins/plugin-cursor.ts'), - '@truenine/plugin-droid-cli': resolve('src/plugins/plugin-droid-cli.ts'), - '@truenine/plugin-editorconfig': resolve('src/plugins/plugin-editorconfig.ts'), - '@truenine/plugin-gemini-cli': resolve('src/plugins/plugin-gemini-cli.ts'), - '@truenine/plugin-git-exclude': resolve('src/plugins/plugin-git-exclude.ts'), - '@truenine/plugin-input-agentskills': resolve('src/plugins/plugin-input-agentskills/index.ts'), - '@truenine/plugin-input-editorconfig': resolve('src/plugins/plugin-input-editorconfig/index.ts'), - '@truenine/plugin-input-fast-command': resolve('src/plugins/plugin-input-fast-command/index.ts'), - '@truenine/plugin-input-git-exclude': resolve('src/plugins/plugin-input-git-exclude/index.ts'), - '@truenine/plugin-input-gitignore': resolve('src/plugins/plugin-input-gitignore/index.ts'), - '@truenine/plugin-input-global-memory': resolve('src/plugins/plugin-input-global-memory/index.ts'), - '@truenine/plugin-input-jetbrains-config': resolve('src/plugins/plugin-input-jetbrains-config/index.ts'), - '@truenine/plugin-input-md-cleanup-effect': resolve('src/plugins/plugin-input-md-cleanup-effect/index.ts'), - '@truenine/plugin-input-orphan-cleanup-effect': resolve('src/plugins/plugin-input-orphan-cleanup-effect/index.ts'), - '@truenine/plugin-input-project-prompt': resolve('src/plugins/plugin-input-project-prompt/index.ts'), - '@truenine/plugin-input-readme': resolve('src/plugins/plugin-input-readme/index.ts'), - '@truenine/plugin-input-rule': resolve('src/plugins/plugin-input-rule/index.ts'), - '@truenine/plugin-input-shadow-project': resolve('src/plugins/plugin-input-shadow-project/index.ts'), - '@truenine/plugin-input-shared-ignore': resolve('src/plugins/plugin-input-shared-ignore/index.ts'), - '@truenine/plugin-input-skill-sync-effect': resolve('src/plugins/plugin-input-skill-sync-effect/index.ts'), - '@truenine/plugin-input-subagent': resolve('src/plugins/plugin-input-subagent/index.ts'), - '@truenine/plugin-input-vscode-config': resolve('src/plugins/plugin-input-vscode-config/index.ts'), - '@truenine/plugin-input-workspace': resolve('src/plugins/plugin-input-workspace/index.ts'), - '@truenine/plugin-jetbrains-ai-codex': resolve('src/plugins/plugin-jetbrains-ai-codex.ts'), - '@truenine/plugin-jetbrains-codestyle': resolve('src/plugins/plugin-jetbrains-codestyle.ts'), - '@truenine/plugin-openai-codex-cli': resolve('src/plugins/plugin-openai-codex-cli.ts'), - '@truenine/plugin-opencode-cli': resolve('src/plugins/plugin-opencode-cli.ts'), - '@truenine/plugin-qoder-ide': resolve('src/plugins/plugin-qoder-ide.ts'), - '@truenine/plugin-readme': resolve('src/plugins/plugin-readme.ts'), - '@truenine/plugin-trae-ide': resolve('src/plugins/plugin-trae-ide.ts'), - '@truenine/plugin-vscode': resolve('src/plugins/plugin-vscode.ts'), - '@truenine/plugin-warp-ide': resolve('src/plugins/plugin-warp-ide.ts'), - '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf.ts'), - '@truenine/plugin-zed': resolve('src/plugins/plugin-zed.ts') -} - -const noExternalDeps = [ - '@truenine/logger', - '@truenine/script-runtime', - 'fast-glob', - 'jiti', - '@truenine/desk-paths', - '@truenine/md-compiler', - ...Object.keys(pluginAliases) -] +const alwaysBundleDeps = ['@truenine/memory-sync-sdk'] export default defineConfig([ { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], + entry: ['./src/index.ts'], platform: 'node', sourcemap: false, unbundle: false, deps: { + alwaysBundle: alwaysBundleDeps, onlyBundle: false }, - alias: { - '@': resolve('src'), - ...pluginAliases - }, - noExternal: noExternalDeps, format: ['esm'], minify: true, dts: {sourcemap: false}, - outputOptions: {exports: 'named'}, - define: { - __CLI_VERSION__: JSON.stringify(pkg.version), - __CLI_PACKAGE_NAME__: JSON.stringify(pkg.name), - __KIRO_GLOBAL_POWERS_REGISTRY__: kiroGlobalPowersRegistry - } + outputOptions: {exports: 'named'} }, { - entry: ['./src/plugin-runtime.ts'], + entry: ['./src/globals.ts'], platform: 'node', sourcemap: false, - unbundle: false, deps: { - onlyBundle: false - }, - alias: { - '@': resolve('src'), - ...pluginAliases + alwaysBundle: alwaysBundleDeps }, - noExternal: noExternalDeps, format: ['esm'], - minify: true, - dts: false, - define: { - __CLI_VERSION__: JSON.stringify(pkg.version), - __CLI_PACKAGE_NAME__: JSON.stringify(pkg.name), - __KIRO_GLOBAL_POWERS_REGISTRY__: kiroGlobalPowersRegistry - } + minify: false, + dts: {sourcemap: false} }, { - entry: ['./src/script-runtime-worker.ts'], + entry: ['./src/plugin-runtime.ts'], platform: 'node', sourcemap: false, - unbundle: false, deps: { - onlyBundle: false + alwaysBundle: alwaysBundleDeps }, - alias: { - '@': resolve('src'), - ...pluginAliases - }, - noExternal: noExternalDeps, format: ['esm'], - minify: false, - dts: false, - define: { - __CLI_VERSION__: JSON.stringify(pkg.version), - __CLI_PACKAGE_NAME__: JSON.stringify(pkg.name), - __KIRO_GLOBAL_POWERS_REGISTRY__: kiroGlobalPowersRegistry - } + minify: true, + dts: false }, { - entry: ['./src/globals.ts'], + entry: ['./src/script-runtime-worker.ts'], platform: 'node', sourcemap: false, - alias: { - '@': resolve('src'), - ...pluginAliases + deps: { + alwaysBundle: alwaysBundleDeps }, format: ['esm'], - minify: false, - dts: {sourcemap: false} + minify: true, + dts: false } ]) diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 1c390295..6d2f1c26 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -1,75 +1,10 @@ -import {readFileSync} from 'node:fs' -import {resolve} from 'node:path' -import {fileURLToPath, URL} from 'node:url' +import {fileURLToPath} from 'node:url' 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/core/desk-paths.ts'), - '@truenine/plugin-output-shared': resolve('src/plugins/plugin-output-shared/index.ts'), - '@truenine/plugin-output-shared/utils': resolve('src/plugins/plugin-output-shared/utils/index.ts'), - '@truenine/plugin-output-shared/registry': resolve('src/plugins/plugin-output-shared/registry/index.ts'), - '@truenine/plugin-input-shared': resolve('src/plugins/plugin-input-shared/index.ts'), - '@truenine/plugin-input-shared/scope': resolve('src/plugins/plugin-input-shared/scope/index.ts'), - '@truenine/plugin-agentskills-compact': resolve('src/plugins/plugin-agentskills-compact.ts'), - '@truenine/plugin-agentsmd': resolve('src/plugins/plugin-agentsmd.ts'), - '@truenine/plugin-antigravity': resolve('src/plugins/plugin-antigravity/index.ts'), - '@truenine/plugin-claude-code-cli': resolve('src/plugins/plugin-claude-code-cli.ts'), - '@truenine/plugin-cursor': resolve('src/plugins/plugin-cursor.ts'), - '@truenine/plugin-droid-cli': resolve('src/plugins/plugin-droid-cli.ts'), - '@truenine/plugin-editorconfig': resolve('src/plugins/plugin-editorconfig.ts'), - '@truenine/plugin-gemini-cli': resolve('src/plugins/plugin-gemini-cli.ts'), - '@truenine/plugin-git-exclude': resolve('src/plugins/plugin-git-exclude.ts'), - '@truenine/plugin-input-agentskills': resolve('src/plugins/plugin-input-agentskills/index.ts'), - '@truenine/plugin-input-editorconfig': resolve('src/plugins/plugin-input-editorconfig/index.ts'), - '@truenine/plugin-input-fast-command': resolve('src/plugins/plugin-input-fast-command/index.ts'), - '@truenine/plugin-input-git-exclude': resolve('src/plugins/plugin-input-git-exclude/index.ts'), - '@truenine/plugin-input-gitignore': resolve('src/plugins/plugin-input-gitignore/index.ts'), - '@truenine/plugin-input-global-memory': resolve('src/plugins/plugin-input-global-memory/index.ts'), - '@truenine/plugin-input-jetbrains-config': resolve('src/plugins/plugin-input-jetbrains-config/index.ts'), - '@truenine/plugin-input-md-cleanup-effect': resolve('src/plugins/plugin-input-md-cleanup-effect/index.ts'), - '@truenine/plugin-input-orphan-cleanup-effect': resolve('src/plugins/plugin-input-orphan-cleanup-effect/index.ts'), - '@truenine/plugin-input-project-prompt': resolve('src/plugins/plugin-input-project-prompt/index.ts'), - '@truenine/plugin-input-readme': resolve('src/plugins/plugin-input-readme/index.ts'), - '@truenine/plugin-input-rule': resolve('src/plugins/plugin-input-rule/index.ts'), - '@truenine/plugin-input-shadow-project': resolve('src/plugins/plugin-input-shadow-project/index.ts'), - '@truenine/plugin-input-shared-ignore': resolve('src/plugins/plugin-input-shared-ignore/index.ts'), - '@truenine/plugin-input-skill-sync-effect': resolve('src/plugins/plugin-input-skill-sync-effect/index.ts'), - '@truenine/plugin-input-subagent': resolve('src/plugins/plugin-input-subagent/index.ts'), - '@truenine/plugin-input-vscode-config': resolve('src/plugins/plugin-input-vscode-config/index.ts'), - '@truenine/plugin-input-workspace': resolve('src/plugins/plugin-input-workspace/index.ts'), - '@truenine/plugin-jetbrains-ai-codex': resolve('src/plugins/plugin-jetbrains-ai-codex.ts'), - '@truenine/plugin-jetbrains-codestyle': resolve('src/plugins/plugin-jetbrains-codestyle.ts'), - '@truenine/plugin-openai-codex-cli': resolve('src/plugins/plugin-openai-codex-cli.ts'), - '@truenine/plugin-opencode-cli': resolve('src/plugins/plugin-opencode-cli.ts'), - '@truenine/plugin-qoder-ide': resolve('src/plugins/plugin-qoder-ide.ts'), - '@truenine/plugin-readme': resolve('src/plugins/plugin-readme.ts'), - '@truenine/plugin-trae-ide': resolve('src/plugins/plugin-trae-ide.ts'), - '@truenine/plugin-vscode': resolve('src/plugins/plugin-vscode.ts'), - '@truenine/plugin-warp-ide': resolve('src/plugins/plugin-warp-ide.ts'), - '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf.ts'), - '@truenine/plugin-zed': resolve('src/plugins/plugin-zed.ts') -} - export default defineConfig({ resolve: { alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - ...workspacePackageAliases, - ...pluginAliases + '@': fileURLToPath(new URL('./src', import.meta.url)) } - }, - define: { - __CLI_VERSION__: JSON.stringify(pkg.version), - __CLI_PACKAGE_NAME__: JSON.stringify(pkg.name), - __KIRO_GLOBAL_POWERS_REGISTRY__: kiroGlobalPowersRegistry } }) diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index c80ffd11..e6571c26 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -12,7 +12,7 @@ export default mergeConfig( passWithNoTests: true, exclude: [...configDefaults.exclude, 'e2e/*'], root: fileURLToPath(new URL('./', import.meta.url)), - setupFiles: ['./test/setup-native-binding.ts'], + setupFiles: ['../sdk/test/setup-native-binding.ts'], typecheck: { enabled: true, tsconfig: './tsconfig.test.json' diff --git a/doc/app/docs/layout.tsx b/doc/app/docs/[[...mdxPath]]/layout.tsx similarity index 63% rename from doc/app/docs/layout.tsx rename to doc/app/docs/[[...mdxPath]]/layout.tsx index 786d3d7a..8f8be510 100644 --- a/doc/app/docs/layout.tsx +++ b/doc/app/docs/[[...mdxPath]]/layout.tsx @@ -1,18 +1,23 @@ import type {ReactNode} from 'react' -import Link from 'next/link' -import {Footer, Layout, Navbar} from 'nextra-theme-docs' +import {Layout, Navbar} from 'nextra-theme-docs' import {getPageMap} from 'nextra/page-map' -import {siteConfig} from '../../lib/site' +import {DocsSectionNav} from '../../../components/docs-section-nav' +import {isDocSectionName} from '../../../lib/docs-sections' +import {siteConfig} from '../../../lib/site' -export default async function DocsLayout({children}: {readonly children: ReactNode}) { - const pageMap = await getPageMap('/docs') - const sectionLinks = [ - {href: '/docs/cli', label: 'CLI'}, - {href: '/docs/mcp', label: 'MCP'}, - {href: '/docs/gui', label: 'GUI'}, - {href: '/docs/technical-details', label: '技术细节'}, - {href: '/docs/design-rationale', label: '设计初衷'} - ] as const +export default async function DocsLayout({ + children, + params: paramsPromise +}: { + readonly children: ReactNode + readonly params: Promise<{readonly mdxPath?: string[]}> +}) { + const params = await paramsPromise + const firstSegment = params.mdxPath?.[0] + const section = firstSegment != null && isDocSectionName(firstSegment) + ? firstSegment + : undefined + const pageMap = await getPageMap(section ? `/docs/${section}` : '/docs') return (
- - + )} - footer={( -
- AGPL-3.0-only · 面向当前仓库实现、命令表面与配置边界 -
- )} docsRepositoryBase={`${siteConfig.docsRepositoryBase}/content`} editLink="在 GitHub 上编辑此页" feedback={{ diff --git a/doc/app/docs/[[...mdxPath]]/page.tsx b/doc/app/docs/[[...mdxPath]]/page.tsx index 782b76d2..4c4786a6 100644 --- a/doc/app/docs/[[...mdxPath]]/page.tsx +++ b/doc/app/docs/[[...mdxPath]]/page.tsx @@ -36,7 +36,7 @@ export default async function DocsPage(props: { sourceCode } = await importPage(params.mdxPath) - const page = + const page = if (!Wrapper) { return page diff --git a/doc/app/docs/[section]/[[...rest]]/page.tsx b/doc/app/docs/[section]/[[...rest]]/page.tsx deleted file mode 100644 index 8e7b3748..00000000 --- a/doc/app/docs/[section]/[[...rest]]/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type {ComponentType, ReactNode} from 'react' -import {notFound} from 'next/navigation' -import {generateStaticParamsFor, importPage} from 'nextra/pages' -import {isDocSectionName} from '../../../../lib/docs-sections' -import {useMDXComponents as getMDXComponents} from '../../../../mdx-components' - -const getAllDocParams = generateStaticParamsFor('mdxPath') - -function isSectionDocParam( - value: {mdxPath?: string[]} -): value is {mdxPath: [string, ...string[]]} { - return value.mdxPath != null - && value.mdxPath.length > 0 - && isDocSectionName(value.mdxPath[0]) -} - -export async function generateStaticParams() { - const allParams = await getAllDocParams() - return (allParams as {mdxPath?: string[]}[]) - .filter(isSectionDocParam) - .map(p => ({ - section: p.mdxPath[0], - rest: p.mdxPath.length > 1 ? p.mdxPath.slice(1) : void 0 - })) -} - -export async function generateMetadata(props: { - readonly params: Promise<{readonly section: string, readonly rest?: string[]}> -}) { - const params = await props.params - if (!isDocSectionName(params.section)) notFound() - const mdxPath = [params.section, ...params.rest ?? []] - const {metadata} = await importPage(mdxPath) - return metadata -} - -interface WrapperProps { - readonly children: ReactNode - readonly metadata: unknown - readonly sourceCode: string - readonly toc: unknown -} - -const components = getMDXComponents() as { - readonly wrapper?: ComponentType -} - -const Wrapper = components.wrapper - -export default async function SectionPage(props: { - readonly params: Promise<{readonly section: string, readonly rest?: string[]}> -}) { - const params = await props.params - if (!isDocSectionName(params.section)) notFound() - const mdxPath = [params.section, ...params.rest ?? []] - const { - default: MDXContent, - toc, - metadata, - sourceCode - } = await importPage(mdxPath) - - const page = - - if (!Wrapper) { - return page - } - - return ( - - {page} - - ) -} diff --git a/doc/app/docs/[section]/layout.tsx b/doc/app/docs/[section]/layout.tsx deleted file mode 100644 index c2c6ab49..00000000 --- a/doc/app/docs/[section]/layout.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type {ReactNode} from 'react' -import {notFound} from 'next/navigation' -import {Footer, Layout, Navbar} from 'nextra-theme-docs' -import {getPageMap} from 'nextra/page-map' -import {DocsSectionNav} from '../../../components/docs-section-nav' -import {isDocSectionName} from '../../../lib/docs-sections' -import {siteConfig} from '../../../lib/site' - -export default async function SectionLayout({ - children, - params -}: { - readonly children: ReactNode - readonly params: Promise<{readonly section: string}> -}) { - const {section} = await params - if (!isDocSectionName(section)) notFound() - const pageMap = await getPageMap(`/docs/${section}`) - - return ( - - memory-sync -
- )} - > - - - )} - footer={ -
AGPL-3.0-only · 面向当前仓库实现、命令表面与配置边界
- } - docsRepositoryBase={`${siteConfig.docsRepositoryBase}/content`} - editLink="在 GitHub 上编辑此页" - feedback={{ - content: '有遗漏或过时信息?提交 issue', - link: siteConfig.issueUrl, - labels: 'documentation' - }} - sidebar={{ - autoCollapse: false, - defaultMenuCollapseLevel: 99, - defaultOpen: true, - toggleButton: false - }} - toc={{ - float: true, - title: '本页目录', - backToTop: '回到顶部' - }} - themeSwitch={{ - dark: '暗色', - light: '亮色', - system: '系统' - }} - nextThemes={{ - attribute: 'class', - defaultTheme: 'dark', - disableTransitionOnChange: true, - storageKey: 'memory-sync-docs-theme' - }} - > - {children} -
- ) -} diff --git a/doc/app/globals.css b/doc/app/globals.css deleted file mode 100644 index 9a3776fb..00000000 --- a/doc/app/globals.css +++ /dev/null @@ -1,1455 +0,0 @@ -:root, -html.dark { - color-scheme: dark; - --nextra-content-width: 1380px; - --page-bg: #0b0c10; - --page-fg: #fafafa; - --page-fg-soft: #b8bec7; - --page-fg-muted: #8b919a; - --surface: rgba(18, 20, 24, 0.82); - --surface-strong: #101116; - --surface-muted: #14161b; - --surface-subtle: #171a20; - --surface-elevated: #1a1d24; - --surface-overlay: rgba(20, 22, 28, 0.92); - --surface-border: rgba(255, 255, 255, 0.04); - --surface-border-strong: rgba(255, 255, 255, 0.07); - --surface-separator: rgba(255, 255, 255, 0.055); - --surface-highlight: rgba(255, 255, 255, 0.025); - --surface-highlight-strong: rgba(255, 255, 255, 0.045); - --inline-code-bg: rgba(255, 255, 255, 0.032); - --shadow-sm: 0 10px 28px rgba(0, 0, 0, 0.16); - --shadow-md: 0 26px 76px rgba(0, 0, 0, 0.24); - --hero-glow: radial-gradient(circle at top, rgba(255, 255, 255, 0.045), transparent 58%); - --page-gradient: - radial-gradient(circle at top, rgba(255, 255, 255, 0.024), transparent 34%), - linear-gradient(180deg, #0a0b0f 0%, #0d1015 100%); - --button-primary-bg: #fafafa; - --button-primary-fg: #111111; - --button-secondary-bg: rgba(255, 255, 255, 0.028); - --button-secondary-fg: #fafafa; -} - -html.light { - color-scheme: light; - --nextra-content-width: 1380px; - --page-bg: #ffffff; - --page-fg: #111111; - --page-fg-soft: #5f6670; - --page-fg-muted: #7b818b; - --surface: rgba(255, 255, 255, 0.9); - --surface-strong: #ffffff; - --surface-muted: #f6f7f8; - --surface-subtle: #f3f4f6; - --surface-elevated: #f8f9fb; - --surface-overlay: rgba(255, 255, 255, 0.94); - --surface-border: rgba(17, 17, 17, 0.08); - --surface-border-strong: rgba(17, 17, 17, 0.14); - --surface-separator: rgba(17, 17, 17, 0.06); - --surface-highlight: rgba(17, 17, 17, 0.035); - --surface-highlight-strong: rgba(17, 17, 17, 0.055); - --inline-code-bg: rgba(17, 17, 17, 0.035); - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05); - --shadow-md: 0 12px 40px rgba(15, 23, 42, 0.06); - --hero-glow: radial-gradient(circle at top, rgba(0, 0, 0, 0.06), transparent 58%); - --page-gradient: - radial-gradient(circle at top, rgba(0, 0, 0, 0.04), transparent 30%), - linear-gradient(180deg, #ffffff 0%, #fbfbfc 100%); - --button-primary-bg: #111111; - --button-primary-fg: #ffffff; - --button-secondary-bg: rgba(255, 255, 255, 0.82); - --button-secondary-fg: #111111; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html, -body { - min-height: 100%; - margin: 0; - background: var(--page-bg); - color: var(--page-fg); - font-family: - var(--font-sans), - 'PingFang SC', - 'Hiragino Sans GB', - 'Microsoft YaHei', - 'Noto Sans SC', - sans-serif; -} - -html { - font-size: 14px; - scroll-behavior: smooth; -} - -body { - min-height: 100vh; - background-image: var(--page-gradient); - background-attachment: fixed; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; -} - -a { - color: inherit; - text-decoration: none; -} - -code, -pre, -kbd, -samp { - font-family: - var(--font-mono), - 'SFMono-Regular', - Consolas, - 'Liberation Mono', - Menlo, - monospace; -} - -.docs-home, -.not-found-shell { - width: min(1080px, calc(100% - 32px)); - margin: 0 auto; -} - -.docs-home { - padding: 20px 0 88px; -} - -.home-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - padding: 6px 0 18px; -} - -.home-brand { - display: inline-flex; - align-items: baseline; - gap: 10px; -} - -.docs-brand { - display: inline-flex; - align-items: center; - gap: 10px; -} - -.home-brand strong { - font-size: 0.95rem; - font-weight: 600; - letter-spacing: -0.02em; -} - -.home-brand span { - color: var(--page-fg-muted); - font-size: 0.78rem; - letter-spacing: 0.02em; -} - -.docs-brand-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 24px; - padding: 0 8px; - border: 1px solid var(--surface-border); - border-radius: 999px; - background: color-mix(in srgb, var(--surface-strong) 82%, transparent); - color: var(--page-fg-muted); - font-size: 0.68rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.docs-brand-title { - font-size: 0.95rem; - font-weight: 600; - letter-spacing: -0.02em; -} - -.home-topbar-nav { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.home-topbar-nav a, -.docs-nav-link { - display: inline-flex; - align-items: center; - min-height: 34px; - padding: 0 11px; - border: 1px solid transparent; - border-radius: 999px; - color: var(--page-fg-muted); - font-size: 0.86rem; - transition: - border-color 0.2s ease, - background-color 0.2s ease, - color 0.2s ease, - transform 0.2s ease; -} - -.docs-site-navbar nav:not(.docs-navbar-links) { - gap: 14px; -} - -.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(2) { - display: none; -} - -.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(3) { - order: 3; -} - -.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(4) { - order: 2; -} - -.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(5) { - order: 4; -} - -.docs-navbar-shell { - display: inline-flex; - flex: 1 1 auto; - align-items: center; - justify-content: flex-end; - gap: 12px; - min-width: 0; - overflow: hidden; -} - -.docs-navbar-links { - display: inline-flex; - align-items: center; - flex-wrap: nowrap; - justify-content: flex-end; - gap: 8px; - min-width: 0; - overflow-x: auto; - padding-bottom: 2px; -} - -.docs-navbar-actions { - display: inline-flex; - align-items: center; - gap: 10px; -} - -.docs-navbar-action { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 36px; - padding: 0 12px; - border: 1px solid var(--surface-border); - border-radius: 999px; - background: color-mix(in srgb, var(--surface-strong) 84%, transparent); - color: var(--page-fg-soft); - font-size: 0.84rem; - font-weight: 500; - transition: - border-color 0.2s ease, - background-color 0.2s ease, - color 0.2s ease, - transform 0.2s ease; -} - -.home-topbar-nav a:hover, -.docs-nav-link:hover, -.docs-navbar-action:hover { - border-color: var(--surface-border); - background: var(--surface); - color: var(--page-fg); - transform: translateY(-1px); -} - -.home-hero { - padding: 18px 0 8px; -} - -.home-hero-copy, -.home-link-card, -.capability-card, -.reading-path-card, -.not-found-shell { - position: relative; - overflow: hidden; - border: 1px solid var(--surface-border); - border-radius: 24px; - background: var(--surface); - box-shadow: var(--shadow-sm); -} - -.home-hero-copy { - padding: 34px 34px 30px; - background-image: - linear-gradient(180deg, color-mix(in srgb, var(--surface-strong) 92%, transparent), var(--surface)), - var(--hero-glow); - box-shadow: var(--shadow-md); -} - -.home-hero-copy::before, -.home-link-card::before, -.capability-card::before, -.reading-path-card::before { - content: ''; - position: absolute; - inset: 0 auto auto 0; - width: 100%; - height: 1px; - background: linear-gradient(90deg, transparent, var(--surface-border-strong), transparent); -} - -.section-kicker { - margin: 0 0 10px; - color: var(--page-fg-muted); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.home-hero h1, -.section-heading h2, -.not-found-shell h1 { - margin: 0; - letter-spacing: -0.045em; -} - -.home-hero h1 { - max-width: 13ch; - font-size: clamp(1.95rem, 3.2vw, 2.9rem); - line-height: 0.98; -} - -.home-hero-lead { - max-width: 52rem; - margin: 16px 0 0; - color: var(--page-fg-soft); - font-size: 0.97rem; - line-height: 1.72; -} - -.home-actions, -.not-found-actions { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 22px; -} - -.hero-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 40px; - padding: 0 15px; - border: 1px solid var(--surface-border-strong); - border-radius: 999px; - font-size: 0.88rem; - font-weight: 500; - transition: - border-color 0.2s ease, - background-color 0.2s ease, - color 0.2s ease, - transform 0.2s ease, - box-shadow 0.2s ease; -} - -.hero-button:hover { - transform: translateY(-1px); -} - -.hero-button-primary { - border-color: var(--button-primary-bg); - background: var(--button-primary-bg); - color: var(--button-primary-fg); -} - -.hero-button-primary:hover { - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08); - opacity: 0.96; -} - -.hero-button-secondary { - background: var(--button-secondary-bg); - color: var(--button-secondary-fg); -} - -.hero-button-secondary:hover { - background: var(--surface-subtle); -} - -.home-proof-strip, -.home-link-grid, -.capability-grid, -.reading-path-grid { - display: grid; - gap: 14px; -} - -.home-proof-strip { - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin: 24px 0 0; - padding: 0; - list-style: none; -} - -.proof-pill { - padding: 14px 16px; - border: 1px solid var(--surface-border); - border-radius: 18px; - background: color-mix(in srgb, var(--surface-strong) 74%, transparent); -} - -.proof-pill span, -.home-link-card span, -.capability-card span, -.reading-path-card small { - color: var(--page-fg-muted); - font-size: 0.74rem; - font-weight: 600; - letter-spacing: 0.07em; - text-transform: uppercase; -} - -.proof-pill strong, -.home-link-card strong, -.capability-card h3, -.reading-path-card strong { - display: block; - margin: 8px 0 0; - font-size: 0.98rem; - font-weight: 600; - letter-spacing: -0.02em; -} - -.home-section { - padding-top: 34px; -} - -.section-heading { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 18px; -} - -.section-heading h2 { - max-width: 22ch; - font-size: clamp(1.45rem, 2.2vw, 1.9rem); - line-height: 1.06; -} - -.section-summary { - max-width: 44rem; - margin: 0; - color: var(--page-fg-soft); - font-size: 0.93rem; - line-height: 1.66; -} - -.home-link-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.capability-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.reading-path-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - -.home-link-card, -.capability-card, -.reading-path-card { - padding: 20px; - transition: - transform 0.24s ease, - border-color 0.24s ease, - background-color 0.24s ease, - box-shadow 0.24s ease; -} - -.home-link-card:hover, -.capability-card:hover, -.reading-path-card:hover { - border-color: var(--surface-border-strong); - background: color-mix(in srgb, var(--surface-strong) 94%, var(--surface-subtle)); - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.home-link-card p, -.capability-card p, -.reading-path-card p, -.not-found-shell p { - margin: 10px 0 0; - color: var(--page-fg-soft); - font-size: 0.92rem; - line-height: 1.68; -} - -.home-link-card span { - display: inline-flex; - margin-top: 16px; -} - -.capability-card span { - display: inline-flex; -} - -.reading-path-card small { - display: inline-flex; -} - -.not-found-shell { - margin-top: 8vh; - padding: 30px; -} - -.not-found-code { - margin: 0 0 12px; - color: var(--page-fg-muted); - font-size: 0.76rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.nextra-content, -.nextra-sidebar, -.nextra-toc, -.nextra-footer, -.nextra-navbar { - font-family: - var(--font-sans), - 'PingFang SC', - 'Hiragino Sans GB', - 'Microsoft YaHei', - 'Noto Sans SC', - sans-serif; -} - -.nextra-nav-container-blur, -.nextra-sidebar-footer, -.nextra-toc, -.nextra-navbar-blur { - backdrop-filter: blur(18px); -} - -.nextra-nav-container-blur, -.nextra-navbar-blur { - background: color-mix(in srgb, var(--page-bg) 84%, transparent); - border-color: var(--surface-border); -} - -.nextra-sidebar, -.nextra-toc, -.nextra-footer { - border-color: var(--surface-border); -} - -.nextra-sidebar, -.nextra-toc { - background: color-mix(in srgb, var(--surface-strong) 90%, transparent); -} - -.nextra-toc { - display: block; -} - -.nextra-sidebar { - border-right: 1px solid var(--surface-border); -} - -.nextra-sidebar::-webkit-scrollbar { - width: 8px; -} - -.nextra-sidebar::-webkit-scrollbar-thumb { - border-radius: 999px; - background: color-mix(in srgb, var(--page-fg-muted) 45%, transparent); -} - -.nextra-sidebar :is(ul, ol) { - gap: 2px; -} - -.nextra-sidebar > div > ul { - gap: 10px; -} - -.nextra-sidebar > div > ul > li:has(> div) > :is(a, button) { - min-height: auto; - margin-top: 14px; - padding: 0 0 8px; - border: none; - border-radius: 0; - background: transparent; - color: var(--page-fg-muted); - font-size: 0.74rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - pointer-events: none; -} - -.nextra-sidebar > div > ul > li:first-child:has(> div) > :is(a, button) { - margin-top: 0; -} - -.nextra-sidebar > div > ul > li:has(> div) > :is(a, button):hover { - border: none; - background: transparent; - color: var(--page-fg-muted); - transform: none; -} - -.nextra-sidebar > div > ul > li:has(> div) > :is(a, button) svg { - display: none; -} - -.nextra-sidebar > div > ul > li:has(> div) > div { - height: auto !important; - overflow: visible !important; - opacity: 1 !important; - transition: none !important; -} - -.nextra-sidebar > div > ul > li:has(> div) > div > ul { - gap: 2px; - padding-left: 0; -} - -.nextra-sidebar a, -.nextra-toc a { - min-height: 34px; - border: 1px solid transparent; - border-radius: 10px; - color: var(--page-fg-soft); - font-size: 0.88rem; - transition: - border-color 0.18s ease, - background-color 0.18s ease, - color 0.18s ease, - transform 0.18s ease; -} - -.nextra-sidebar a:hover, -.nextra-toc a:hover { - border-color: var(--surface-border); - background: color-mix(in srgb, var(--surface-subtle) 84%, transparent); - color: var(--page-fg); - transform: translateX(1px); -} - -.nextra-sidebar :is(a[aria-current='page'], li.active > a) { - border-color: color-mix(in srgb, var(--surface-border-strong) 88%, transparent); - background: color-mix(in srgb, var(--surface-subtle) 88%, transparent); - color: var(--page-fg); -} - -.nextra-sidebar a[href^='#'], -.nextra-sidebar li.active > ul:has(a[href^='#']) { - display: none; -} - -.nextra-sidebar summary, -.nextra-sidebar [data-has-children='true'] { - font-size: 0.88rem; -} - -.nextra-sidebar :is(h2, h3, h4, [data-headings]) { - letter-spacing: -0.01em; -} - -.nextra-body-typesetting-article { - max-width: 920px; -} - -.nextra-body-typesetting-article { - color: var(--page-fg); - font-size: 0.95rem; - line-height: 1.78; -} - -.nextra-body-typesetting-article :where(h1, h2, h3, h4) { - letter-spacing: -0.035em; - line-height: 1.1; -} - -.nextra-body-typesetting-article h1 { - font-size: clamp(1.9rem, 3vw, 2.35rem); - margin-bottom: 0.95rem; -} - -.nextra-body-typesetting-article h2 { - font-size: clamp(1.34rem, 2.1vw, 1.66rem); - margin-top: 2.15rem; -} - -.nextra-body-typesetting-article h3 { - font-size: 1.08rem; - margin-top: 1.6rem; -} - -.nextra-body-typesetting-article h4 { - font-size: 0.98rem; - margin-top: 1.3rem; -} - -.nextra-body-typesetting-article :where(p, li, blockquote) { - color: var(--page-fg-soft); -} - -.nextra-body-typesetting-article a:not(.nextra-card) { - color: var(--page-fg); - text-decoration-line: underline; - text-decoration-color: var(--surface-border-strong); - text-decoration-thickness: 0.06em; - text-underline-offset: 0.22em; -} - -.nextra-body-typesetting-article a:not(.nextra-card):hover { - text-decoration-color: currentColor; -} - -.nextra-body-typesetting-article :not(pre) > code { - padding: 0.16rem 0.38rem; - border: 1px solid var(--surface-border); - border-radius: 0.45rem; - background: var(--surface-subtle); - color: var(--page-fg); - font-size: 0.88em; -} - -.nextra-body-typesetting-article pre { - border: 1px solid var(--surface-border); - border-radius: 18px; - background: color-mix(in srgb, var(--surface-muted) 90%, transparent) !important; - box-shadow: none; - font-size: 0.87rem; - line-height: 1.64; -} - -.nextra-body-typesetting-article pre code { - color: inherit; -} - -.nextra-body-typesetting-article blockquote, -.nextra-callout { - border: 1px solid var(--surface-border); - border-left: 2px solid var(--surface-border-strong); - border-radius: 0 16px 16px 0; - background: color-mix(in srgb, var(--surface-muted) 90%, transparent); -} - -.nextra-body-typesetting-article table { - font-size: 0.92rem; -} - -.nextra-body-typesetting-article :where(th, td) { - border-color: var(--surface-border); -} - -.nextra-body-typesetting-article .nextra-card { - border-color: var(--surface-border); - background: var(--surface); -} - -.nextra-body-typesetting-article .mermaid-diagram { - overflow: hidden; - margin-top: 1.25rem; - border: 1px solid rgba(147, 164, 191, 0.28); - border-radius: 20px; - background: - linear-gradient(180deg, rgba(15, 17, 23, 0.98) 0%, rgba(17, 24, 39, 0.98) 100%), - radial-gradient(circle at top, rgba(111, 142, 207, 0.16), transparent 48%); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.04), - 0 18px 48px rgba(2, 6, 23, 0.28); -} - -.nextra-body-typesetting-article .mermaid-diagram__title { - padding: 14px 18px 0; - color: #9fb0cc; - font-size: 0.76rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.nextra-body-typesetting-article .mermaid-diagram__canvas { - overflow-x: auto; - padding: 18px; -} - -.nextra-body-typesetting-article .mermaid-diagram__canvas svg { - display: block; - height: auto; - margin: 0 auto; -} - -.nextra-body-typesetting-article .mermaid-diagram__loading, -.nextra-body-typesetting-article .mermaid-diagram__error { - color: #d7e0ef; - font-size: 0.88rem; -} - -.nextra-body-typesetting-article .mermaid-diagram__fallback { - margin: 0; - border: 1px solid rgba(147, 164, 191, 0.18); - border-radius: 16px; - background: rgba(15, 17, 23, 0.82) !important; -} - -.nextra-body-typesetting-article .mermaid-diagram__error { - margin: 0; - padding: 0 18px 18px; -} - -.nextra-body-typesetting-article .docs-widget { - margin: 1.5rem 0; -} - -.nextra-body-typesetting-article .docs-widget-header { - margin-bottom: 0.9rem; -} - -.nextra-body-typesetting-article .docs-widget-header h3 { - margin: 0; - font-size: 1rem; -} - -.nextra-body-typesetting-article .docs-widget-header p { - margin: 0.45rem 0 0; - color: var(--page-fg-soft); -} - -.nextra-body-typesetting-article .docs-table-shell { - overflow-x: auto; - border: 1px solid var(--surface-border); - border-radius: 20px; - background: color-mix(in srgb, var(--surface-strong) 92%, transparent); - box-shadow: var(--shadow-sm); -} - -.nextra-body-typesetting-article .docs-widget-table { - width: 100%; - min-width: 720px; - margin: 0; - border: 0; - border-collapse: separate; - border-spacing: 0; - background: transparent; -} - -.nextra-body-typesetting-article .docs-widget-table thead { - background: color-mix(in srgb, var(--surface-subtle) 88%, transparent); -} - -.nextra-body-typesetting-article .docs-widget-table :where(th, td) { - vertical-align: top; - border-right: 1px solid var(--surface-border); - border-bottom: 1px solid var(--surface-border); -} - -.nextra-body-typesetting-article .docs-widget-table :where(th, td):last-child { - border-right: 0; -} - -.nextra-body-typesetting-article .docs-widget-table tbody tr:last-child td { - border-bottom: 0; -} - -.nextra-body-typesetting-article .docs-cell-heading { - display: flex; - flex-direction: column; - gap: 0.45rem; -} - -.nextra-body-typesetting-article .docs-muted { - color: var(--page-fg-muted); -} - -.nextra-body-typesetting-article .docs-table-list, -.nextra-body-typesetting-article .docs-platform-card__highlights { - margin: 0; - padding-left: 1.1rem; -} - -.nextra-body-typesetting-article .docs-table-list li, -.nextra-body-typesetting-article .docs-platform-card__highlights li { - margin: 0.15rem 0; - color: var(--page-fg-soft); -} - -.nextra-body-typesetting-article .docs-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 26px; - padding: 0 0.7rem; - border: 1px solid var(--surface-border); - border-radius: 999px; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.nextra-body-typesetting-article .docs-badge--stable, -.nextra-body-typesetting-article .docs-badge--full { - border-color: rgba(20, 132, 86, 0.22); - background: rgba(20, 132, 86, 0.1); - color: #116149; -} - -.nextra-body-typesetting-article .docs-badge--partial, -.nextra-body-typesetting-article .docs-badge--beta { - border-color: rgba(180, 83, 9, 0.2); - background: rgba(180, 83, 9, 0.1); - color: #92400e; -} - -.nextra-body-typesetting-article .docs-badge--planned, -.nextra-body-typesetting-article .docs-badge--experimental, -.nextra-body-typesetting-article .docs-badge--info { - border-color: rgba(37, 99, 235, 0.18); - background: rgba(37, 99, 235, 0.08); - color: #1d4ed8; -} - -.nextra-body-typesetting-article .docs-badge--deprecated, -.nextra-body-typesetting-article .docs-badge--unsupported { - border-color: rgba(185, 28, 28, 0.18); - background: rgba(185, 28, 28, 0.08); - color: #b91c1c; -} - -.nextra-body-typesetting-article .docs-platform-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; -} - -.nextra-body-typesetting-article .docs-platform-card { - position: relative; - overflow: hidden; - padding: 20px; - border: 1px solid var(--surface-border); - border-radius: 22px; - background: - linear-gradient(180deg, color-mix(in srgb, var(--surface-strong) 94%, transparent), var(--surface)), - var(--hero-glow); - box-shadow: var(--shadow-sm); -} - -.nextra-body-typesetting-article .docs-platform-card::before { - content: ''; - position: absolute; - inset: 0 auto auto 0; - width: 100%; - height: 1px; - background: linear-gradient(90deg, transparent, var(--surface-border-strong), transparent); -} - -.nextra-body-typesetting-article .docs-platform-card__top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; -} - -.nextra-body-typesetting-article .docs-platform-card__family { - display: inline-flex; - margin-bottom: 0.5rem; - color: var(--page-fg-muted); - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.nextra-body-typesetting-article .docs-platform-card h3 { - margin: 0; - font-size: 1.02rem; -} - -.nextra-body-typesetting-article .docs-platform-card p { - margin: 0.75rem 0 0; -} - -.nextra-body-typesetting-article .docs-command-stack { - display: flex; - flex-direction: column; - gap: 0.45rem; -} - -.nextra-body-typesetting-article .docs-command-chip { - display: block; - width: fit-content; - max-width: min(100%, 36rem); - padding: 0.45rem 0.6rem; - border: 1px solid var(--surface-border); - border-radius: 12px; - background: color-mix(in srgb, var(--surface-subtle) 90%, transparent); - color: var(--page-fg); - white-space: pre-wrap; - word-break: break-word; -} - -html.dark .home-topbar-nav a:hover, -html.dark .docs-nav-link:hover, -html.dark .docs-navbar-action:hover { - border-color: var(--surface-border); - background: var(--surface-highlight); -} - -html.dark .home-hero-copy, -html.dark .home-link-card, -html.dark .capability-card, -html.dark .reading-path-card, -html.dark .not-found-shell, -html.dark .proof-pill { - border-color: var(--surface-border); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-overlay) 92%, transparent), - color-mix(in srgb, var(--surface) 94%, transparent) - ); - box-shadow: - inset 0 1px 0 var(--surface-highlight), - var(--shadow-sm); -} - -html.dark .home-hero-copy { - box-shadow: - inset 0 1px 0 var(--surface-highlight), - var(--shadow-md); -} - -html.dark .home-hero-copy::before, -html.dark .home-link-card::before, -html.dark .capability-card::before, -html.dark .reading-path-card::before { - background: linear-gradient(90deg, transparent, var(--surface-separator), transparent); -} - -html.dark .proof-pill { - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-elevated) 82%, transparent), - color-mix(in srgb, var(--surface-overlay) 88%, transparent) - ); -} - -html.dark .home-link-card:hover, -html.dark .capability-card:hover, -html.dark .reading-path-card:hover { - border-color: var(--surface-border); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-elevated) 88%, transparent), - color-mix(in srgb, var(--surface-overlay) 96%, transparent) - ); - box-shadow: - inset 0 1px 0 var(--surface-highlight-strong), - 0 22px 54px rgba(0, 0, 0, 0.2); -} - -html.dark .hero-button { - border-color: var(--surface-border); - box-shadow: inset 0 1px 0 var(--surface-highlight); -} - -html.dark .hero-button-primary { - border-color: rgba(255, 255, 255, 0.08); - background: color-mix(in srgb, var(--button-primary-bg) 95%, transparent); - color: var(--button-primary-fg); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.18), - 0 16px 40px rgba(0, 0, 0, 0.18); -} - -html.dark .hero-button-primary:hover { - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.22), - 0 18px 42px rgba(0, 0, 0, 0.22); -} - -html.dark .hero-button-secondary { - border-color: var(--surface-border); - background: var(--button-secondary-bg); - color: var(--button-secondary-fg); -} - -html.dark .hero-button-secondary:hover { - background: color-mix(in srgb, var(--surface-overlay) 72%, var(--surface-highlight)); -} - -html.dark .nextra-nav-container-blur, -html.dark .nextra-navbar-blur { - background: color-mix(in srgb, var(--page-bg) 78%, var(--surface-overlay)); - border-color: var(--surface-separator); -} - -html.dark .docs-brand-badge { - border-color: var(--surface-border); - background: color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-overlay)); -} - -html.dark .docs-navbar-action { - border-color: var(--surface-border); - background: color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-overlay)); -} - -html.dark .nextra-sidebar, -html.dark .nextra-toc { - border-color: var(--surface-separator); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-strong) 94%, transparent), - color-mix(in srgb, var(--surface-overlay) 96%, transparent) - ); - box-shadow: inset 0 1px 0 var(--surface-highlight); -} - -html.dark .nextra-sidebar { - border-right-color: var(--surface-separator); -} - -html.dark .nextra-footer { - border-color: var(--surface-separator); - color: var(--page-fg-muted); -} - -html.dark .nextra-sidebar a, -html.dark .nextra-toc a { - border-color: transparent; -} - -html.dark .nextra-sidebar a:hover, -html.dark .nextra-toc a:hover { - border-color: var(--surface-border); - background: color-mix(in srgb, var(--surface-highlight-strong) 100%, var(--surface-overlay)); - transform: translateX(0); -} - -html.dark .nextra-sidebar :is(a[aria-current='page'], li.active > a), -html.dark .nextra-toc a[data-active='true'] { - border-color: var(--surface-border); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-highlight-strong) 100%, var(--surface-elevated)), - color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-overlay)) - ); - box-shadow: inset 0 1px 0 var(--surface-highlight); -} - -html.dark .nextra-body-typesetting-article a:not(.nextra-card) { - text-decoration-color: var(--surface-separator); -} - -html.dark .nextra-body-typesetting-article hr { - border-color: var(--surface-separator); - opacity: 0.72; -} - -html.dark .nextra-body-typesetting-article :not(pre) > code { - border-color: var(--surface-border); - background: var(--inline-code-bg); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.024); -} - -html.dark .nextra-body-typesetting-article pre { - border-color: var(--surface-border); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-muted) 94%, transparent), - color-mix(in srgb, var(--surface-elevated) 92%, transparent) - ) !important; - box-shadow: - inset 0 1px 0 var(--surface-highlight), - 0 18px 44px rgba(0, 0, 0, 0.14); -} - -html.dark .nextra-body-typesetting-article blockquote, -html.dark .nextra-callout { - border-color: var(--surface-border); - border-left-color: var(--surface-separator); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-muted)), - color-mix(in srgb, var(--surface-overlay) 92%, transparent) - ); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); -} - -html.dark .nextra-body-typesetting-article table { - overflow: hidden; - border: 1px solid var(--surface-border); - border-collapse: separate; - border-spacing: 0; - border-radius: 18px; - background: color-mix(in srgb, var(--surface-overlay) 96%, transparent); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); -} - -html.dark .nextra-body-typesetting-article thead { - background: color-mix(in srgb, var(--surface-highlight-strong) 100%, var(--surface-elevated)); -} - -html.dark .nextra-body-typesetting-article tbody tr { - background: color-mix(in srgb, var(--surface-overlay) 94%, transparent); -} - -html.dark .nextra-body-typesetting-article tbody tr:nth-child(even) { - background: color-mix(in srgb, var(--surface-muted) 82%, transparent); -} - -html.dark .nextra-body-typesetting-article tbody tr:hover { - background: color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-elevated)); -} - -html.dark .nextra-body-typesetting-article :where(th, td) { - border-color: var(--surface-separator); -} - -html.dark .nextra-body-typesetting-article .nextra-card { - border-color: var(--surface-border); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-overlay) 94%, transparent), - color-mix(in srgb, var(--surface) 92%, transparent) - ); - box-shadow: - inset 0 1px 0 var(--surface-highlight), - var(--shadow-sm); -} - -html.dark .nextra-body-typesetting-article .mermaid-diagram { - border-color: var(--surface-border); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-muted) 96%, transparent), - color-mix(in srgb, var(--surface-elevated) 92%, transparent) - ); - box-shadow: - inset 0 1px 0 var(--surface-highlight), - 0 18px 44px rgba(0, 0, 0, 0.16); -} - -html.dark .nextra-body-typesetting-article .mermaid-diagram__title { - color: var(--page-fg-muted); -} - -html.dark .nextra-body-typesetting-article .mermaid-diagram__loading, -html.dark .nextra-body-typesetting-article .mermaid-diagram__error { - color: var(--page-fg-soft); -} - -html.dark .nextra-body-typesetting-article .mermaid-diagram__fallback { - border-color: var(--surface-border); - background: color-mix(in srgb, var(--surface-overlay) 96%, transparent) !important; -} - -html.dark .nextra-body-typesetting-article .docs-table-shell { - border-color: var(--surface-border); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-overlay) 96%, transparent), - color-mix(in srgb, var(--surface) 92%, transparent) - ); - box-shadow: - inset 0 1px 0 var(--surface-highlight), - var(--shadow-sm); -} - -html.dark .nextra-body-typesetting-article .docs-widget-table thead { - background: color-mix(in srgb, var(--surface-highlight-strong) 100%, var(--surface-elevated)); -} - -html.dark .nextra-body-typesetting-article .docs-widget-table :where(th, td) { - border-color: var(--surface-separator); -} - -html.dark .nextra-body-typesetting-article .docs-badge--stable, -html.dark .nextra-body-typesetting-article .docs-badge--full { - border-color: rgba(74, 222, 128, 0.18); - background: rgba(74, 222, 128, 0.1); - color: #86efac; -} - -html.dark .nextra-body-typesetting-article .docs-badge--partial, -html.dark .nextra-body-typesetting-article .docs-badge--beta { - border-color: rgba(251, 191, 36, 0.16); - background: rgba(251, 191, 36, 0.1); - color: #fcd34d; -} - -html.dark .nextra-body-typesetting-article .docs-badge--planned, -html.dark .nextra-body-typesetting-article .docs-badge--experimental, -html.dark .nextra-body-typesetting-article .docs-badge--info { - border-color: rgba(96, 165, 250, 0.16); - background: rgba(96, 165, 250, 0.1); - color: #93c5fd; -} - -html.dark .nextra-body-typesetting-article .docs-badge--deprecated, -html.dark .nextra-body-typesetting-article .docs-badge--unsupported { - border-color: rgba(248, 113, 113, 0.16); - background: rgba(248, 113, 113, 0.1); - color: #fca5a5; -} - -html.dark .nextra-body-typesetting-article .docs-platform-card { - border-color: var(--surface-border); - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-overlay) 94%, transparent), - color-mix(in srgb, var(--surface) 92%, transparent) - ); - box-shadow: - inset 0 1px 0 var(--surface-highlight), - var(--shadow-sm); -} - -html.dark .nextra-body-typesetting-article .docs-platform-card::before { - background: linear-gradient(90deg, transparent, var(--surface-separator), transparent); -} - -html.dark .nextra-body-typesetting-article .docs-command-chip { - border-color: var(--surface-border); - background: color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-elevated)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); -} - -.nextra-footer { - color: var(--page-fg-muted); -} - -.motion-rise { - animation: rise-in 0.56s cubic-bezier(0.16, 1, 0.3, 1) both; -} - -.motion-stagger > * { - animation: rise-in 0.56s cubic-bezier(0.16, 1, 0.3, 1) both; -} - -.motion-stagger > :nth-child(1) { - animation-delay: 0.04s; -} - -.motion-stagger > :nth-child(2) { - animation-delay: 0.1s; -} - -.motion-stagger > :nth-child(3) { - animation-delay: 0.16s; -} - -.motion-stagger > :nth-child(4) { - animation-delay: 0.22s; -} - -@keyframes rise-in { - from { - opacity: 0; - transform: translateY(16px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (max-width: 1080px) { - .reading-path-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 960px) { - .docs-home, - .not-found-shell { - width: min(100% - 24px, 1080px); - } - - .home-topbar { - flex-direction: column; - align-items: flex-start; - } - - .home-hero-copy { - padding: 28px 24px 24px; - } - - .home-proof-strip, - .home-link-grid, - .capability-grid, - .reading-path-grid { - grid-template-columns: minmax(0, 1fr); - } - - .nextra-body-typesetting-article .docs-platform-grid { - grid-template-columns: 1fr; - } - - .home-hero h1 { - max-width: 13ch; - font-size: clamp(1.8rem, 10vw, 2.45rem); - } - - .docs-site-navbar nav:not(.docs-navbar-links) { - gap: 12px; - } -} - -@media (max-width: 768px) { - .docs-brand-badge, - .docs-navbar-shell { - display: none; - } -} - -@media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } - - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} diff --git a/doc/app/globals.scss b/doc/app/globals.scss new file mode 100644 index 00000000..2a045563 --- /dev/null +++ b/doc/app/globals.scss @@ -0,0 +1,1241 @@ +:root, +html.dark { + color-scheme: dark; + --nextra-content-width: min(1760px, calc(100vw - 16px)); + --page-bg: #000000; + --page-fg: #ffffff; + --page-fg-soft: #a1a1a1; + --page-fg-muted: #666666; + --surface: rgba(0, 0, 0, 0.8); + --surface-strong: #000000; + --surface-muted: #111111; + --surface-subtle: #171717; + --surface-elevated: #1f1f1f; + --surface-overlay: rgba(0, 0, 0, 0.9); + --surface-border: rgba(255, 255, 255, 0.1); + --surface-border-strong: rgba(255, 255, 255, 0.15); + --surface-separator: rgba(255, 255, 255, 0.08); + --surface-highlight: rgba(255, 255, 255, 0.03); + --surface-highlight-strong: rgba(255, 255, 255, 0.06); + --inline-code-bg: rgba(255, 255, 255, 0.05); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1); + --shadow-md: 0 12px 40px rgba(0, 0, 0, 0.2); + --hero-glow: radial-gradient(circle at top, rgba(255, 255, 255, 0.05), transparent 60%); + --page-gradient: linear-gradient(180deg, #000000 0%, #000000 100%); + --button-primary-bg: #ffffff; + --button-primary-fg: #000000; + --button-secondary-bg: rgba(255, 255, 255, 0.05); + --button-secondary-fg: #ffffff; + --accent: #ffffff; +} + +html.light { + color-scheme: light; + --nextra-content-width: min(1760px, calc(100vw - 16px)); + --page-bg: #ffffff; + --page-fg: #000000; + --page-fg-soft: #666666; + --page-fg-muted: #888888; + --surface: rgba(255, 255, 255, 0.9); + --surface-strong: #ffffff; + --surface-muted: #fafafa; + --surface-subtle: #f5f5f5; + --surface-elevated: #f0f0f0; + --surface-overlay: rgba(255, 255, 255, 0.95); + --surface-border: rgba(0, 0, 0, 0.08); + --surface-border-strong: rgba(0, 0, 0, 0.12); + --surface-separator: rgba(0, 0, 0, 0.06); + --surface-highlight: rgba(0, 0, 0, 0.03); + --surface-highlight-strong: rgba(0, 0, 0, 0.05); + --inline-code-bg: rgba(0, 0, 0, 0.05); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 12px 40px rgba(0, 0, 0, 0.06); + --hero-glow: radial-gradient(circle at top, rgba(0, 0, 0, 0.04), transparent 60%); + --page-gradient: linear-gradient(180deg, #ffffff 0%, #ffffff 100%); + --button-primary-bg: #000000; + --button-primary-fg: #ffffff; + --button-secondary-bg: rgba(0, 0, 0, 0.05); + --button-secondary-fg: #000000; + --accent: #000000; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; + background: var(--page-bg); + color: var(--page-fg); + font-family: + var(--font-sans), + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Arial, + sans-serif; +} + +html { + font-size: 14px; + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + background-image: var(--page-gradient); + background-attachment: fixed; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +code, +pre, +kbd, +samp { + font-family: + var(--font-mono), + 'SFMono-Regular', + Consolas, + 'Liberation Mono', + Menlo, + monospace; +} + +.docs-home, +.not-found-shell { + width: min(1080px, calc(100% - 32px)); + margin: 0 auto; +} + +.docs-home { + padding: 40px 0 88px; +} + +.home-site-footer { + margin-top: 72px; + padding-top: 24px; + border-top: 1px solid var(--surface-border); +} + +.home-site-footer__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + color: var(--page-fg-muted); + font-size: 0.8125rem; + line-height: 1.6; +} + +.home-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 0 0 24px; + border-bottom: 1px solid var(--surface-border); + margin-bottom: 48px; +} + +.home-brand { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.home-brand strong { + font-size: 1.1rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.home-brand span { + color: var(--page-fg-muted); + font-size: 0.8rem; + font-weight: 500; + padding: 2px 8px; + border: 1px solid var(--surface-border); + border-radius: 999px; + background: var(--surface-subtle); +} + +.docs-brand { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.docs-brand-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--surface-border); + border-radius: 6px; + background: var(--surface-subtle); + color: var(--page-fg-muted); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.docs-brand-title { + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.home-topbar-nav { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.home-topbar-nav a, +.docs-nav-link { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 12px; + border-radius: 6px; + color: var(--page-fg-soft); + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.home-topbar-nav a:hover, +.docs-nav-link:hover { + background: var(--surface-highlight); + color: var(--page-fg); +} + +.docs-nav-link.is-active { + color: var(--page-fg); + background: var(--surface-highlight-strong); +} + +.docs-navbar-shell { + display: flex; + flex: 1; + align-items: center; + justify-content: flex-end; + gap: 16px; +} + +.docs-navbar-links { + display: flex; + align-items: center; + gap: 4px; +} + +.docs-navbar-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: 8px; + padding-left: 16px; + border-left: 1px solid var(--surface-separator); +} + +.docs-navbar-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--surface-border); + border-radius: 6px; + background: var(--surface-strong); + color: var(--page-fg-soft); + font-size: 0.84rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.docs-navbar-action:hover { + border-color: var(--surface-border-strong); + color: var(--page-fg); +} + +.home-hero { + padding: 0 0 64px; +} + +.home-hero-copy { + padding: 0; + background: transparent; + box-shadow: none; +} + +.home-hero-copy::before { + display: none; +} + +.section-kicker { + margin: 0 0 16px; + color: var(--page-fg-muted); + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.home-hero h1 { + margin: 0; + font-size: clamp(2.5rem, 5vw, 4rem); + font-weight: 800; + letter-spacing: -0.04em; + line-height: 1.1; + color: var(--page-fg); +} + +.home-hero-lead { + max-width: 48rem; + margin: 24px 0 0; + color: var(--page-fg-soft); + font-size: 1.15rem; + line-height: 1.6; +} + +.home-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 32px; +} + +.hero-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 24px; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + transition: all 0.2s ease; +} + +.hero-button-primary { + background: var(--button-primary-bg); + color: var(--button-primary-fg); + border: 1px solid var(--button-primary-bg); +} + +.hero-button-primary:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.hero-button-secondary { + background: var(--button-secondary-bg); + color: var(--button-secondary-fg); + border: 1px solid var(--surface-border); +} + +.hero-button-secondary:hover { + background: var(--surface-highlight); + border-color: var(--surface-border-strong); + transform: translateY(-1px); +} + +.home-proof-strip { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + margin: 64px 0 0; + padding: 0; + list-style: none; +} + +.proof-pill { + padding: 24px; + border: 1px solid var(--surface-border); + border-radius: 12px; + background: var(--surface-subtle); +} + +.proof-pill span { + color: var(--page-fg-muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.proof-pill strong { + display: block; + margin: 12px 0 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--page-fg); +} + +.home-contributors-shell { + display: grid; + gap: 20px; + padding: 28px; + border: 1px solid var(--surface-border); + border-radius: 20px; + background: + radial-gradient(circle at top, var(--surface-highlight-strong), transparent 58%), + var(--surface-strong); + box-shadow: var(--shadow-sm); +} + +.home-contributors-group { + display: grid; + gap: 14px; +} + +.home-contributors-group__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.home-contributors-group__header strong { + color: var(--page-fg); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.home-contributors-group__header span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + min-height: 28px; + padding: 0 8px; + border: 1px solid var(--surface-border); + border-radius: 999px; + color: var(--page-fg-muted); + font-size: 0.8rem; + font-weight: 600; + background: var(--surface-highlight); +} + +.home-contributors-grid { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.home-contributor { + display: inline-flex; + border-radius: 999px; + transition: + transform 0.18s ease, + box-shadow 0.18s ease; +} + +.home-contributor:hover { + transform: translateY(-2px); +} + +.home-contributor-avatar { + display: block; + width: 72px; + height: 72px; + border: 1px solid var(--surface-border-strong); + border-radius: 999px; + background: var(--surface-muted); + object-fit: cover; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22); +} + +.home-contributors-link { + display: inline-flex; + align-items: center; + width: fit-content; + min-height: 38px; + padding: 0 14px; + border: 1px solid var(--surface-border); + border-radius: 999px; + color: var(--page-fg-soft); + font-size: 0.875rem; + font-weight: 500; + transition: + color 0.18s ease, + border-color 0.18s ease, + background-color 0.18s ease; +} + +.home-contributors-link:hover { + color: var(--page-fg); + border-color: var(--surface-border-strong); + background: var(--surface-highlight); +} + +.home-section { + padding-top: 80px; +} + +.section-heading { + margin-bottom: 40px; +} + +.section-heading h2 { + font-size: 2.25rem; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0; +} + +.section-summary { + max-width: 44rem; + margin: 16px 0 0; + color: var(--page-fg-soft); + font-size: 1.1rem; + line-height: 1.6; +} + +.home-link-grid, +.capability-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.reading-path-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.home-link-card, +.capability-card, +.reading-path-card { + padding: 32px; + border: 1px solid var(--surface-border); + border-radius: 12px; + background: var(--surface-strong); + transition: all 0.2s ease; +} + +.home-link-card:hover, +.capability-card:hover, +.reading-path-card:hover { + border-color: var(--surface-border-strong); + background: var(--surface-subtle); + transform: translateY(-2px); +} + +.home-link-card strong, +.capability-card h3, +.reading-path-card strong { + display: block; + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 12px; +} + +.home-link-card p, +.capability-card p, +.reading-path-card p { + color: var(--page-fg-soft); + font-size: 0.95rem; + line-height: 1.6; + margin: 0; +} + +.reading-path-card small { + display: inline-block; + margin-bottom: 12px; + color: var(--page-fg-muted); + font-size: 0.75rem; + font-weight: 700; +} + +.home-link-card span { + display: inline-block; + margin-top: 24px; + font-size: 0.85rem; + font-weight: 600; + color: var(--page-fg-muted); +} + +.capability-card span { + display: inline-block; + margin-bottom: 12px; + color: var(--page-fg-muted); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.nextra-nav-container-blur, +.nextra-navbar-blur { + backdrop-filter: blur(12px); + background: rgba(0, 0, 0, 0.7) !important; + border-bottom: 1px solid var(--surface-separator); +} + +.nextra-sidebar { + background: transparent !important; + border-right: 1px solid var(--surface-separator); +} + +.nextra-sidebar a { + border-radius: 6px; + transition: all 0.1s ease; + font-size: 0.9rem; + padding: 6px 12px; + color: var(--page-fg-soft); +} + +.nextra-sidebar a:hover { + background: var(--surface-highlight); + color: var(--page-fg); +} + +.nextra-sidebar :is(a[aria-current='page'], li.active > a) { + background: var(--surface-highlight-strong); + color: var(--page-fg); + font-weight: 600; +} + +.nextra-toc { + background: transparent !important; + border-left: 1px solid var(--surface-separator); +} + +.nextra-toc a { + font-size: 0.85rem; + color: var(--page-fg-soft); +} + +.nextra-toc a[data-active='true'] { + color: var(--page-fg); + font-weight: 600; +} + +@media (min-width: 1024px) { + .nextra-sidebar, + .nextra-toc { + width: 12.5rem; + } + + .nextra-sidebar + article { + padding-left: 1.75rem; + padding-right: 1.75rem; + } +} + +main[data-pagefind-body], +.nextra-body-typesetting-article { + max-width: 980px; + padding-top: 40px; + padding-bottom: 80px; +} + +main[data-pagefind-body] h1, +.nextra-body-typesetting-article h1 { + margin: 0; + color: var(--page-fg); + font-size: 2.5rem; + line-height: 1.1; + font-weight: 700; + letter-spacing: -0.04em; +} + +main[data-pagefind-body] > div:first-of-type:not(.docs-code-block) { + margin-top: 8px; + margin-bottom: 24px; + color: var(--page-fg-muted); + font-size: 0.875rem; + line-height: 1.5; +} + +main[data-pagefind-body] h2, +.nextra-body-typesetting-article h2 { + position: relative; + margin: 56px 0 16px; + padding: 0; + border: 0; + color: var(--page-fg); + font-size: 1.5rem; + line-height: 1.35; + font-weight: 600; + letter-spacing: -0.03em; +} + +main[data-pagefind-body] h3, +.nextra-body-typesetting-article h3 { + margin: 40px 0 12px; + color: var(--page-fg); + font-size: 1.125rem; + line-height: 1.45; + font-weight: 600; + letter-spacing: -0.02em; +} + +main[data-pagefind-body] p, +.nextra-body-typesetting-article p { + margin: 0 0 20px; + color: var(--page-fg-soft); + font-size: 1rem; + line-height: 1.75; +} + +main[data-pagefind-body] ol, +main[data-pagefind-body] ul, +.nextra-body-typesetting-article ol, +.nextra-body-typesetting-article ul { + margin: 0 0 24px; + padding-left: 1.5rem; + color: var(--page-fg-soft); +} + +main[data-pagefind-body] ol { + list-style: decimal; +} + +main[data-pagefind-body] ul { + list-style: disc; +} + +main[data-pagefind-body] li, +.nextra-body-typesetting-article li { + margin: 0.5rem 0; + font-size: 1rem; + line-height: 1.75; +} + +main[data-pagefind-body] li > p:last-child, +.nextra-body-typesetting-article li > p:last-child { + margin-bottom: 0; +} + +main[data-pagefind-body] a:not(.hero-button):not(.home-brand):not(.home-topbar-nav a):not(.docs-navbar-action):not(.docs-nav-link):not(.subheading-anchor), +.nextra-body-typesetting-article a:not(.nextra-card) { + color: var(--page-fg); + text-decoration: underline; + text-decoration-color: var(--surface-border-strong); + text-underline-offset: 0.16em; + transition: + color 0.18s ease, + text-decoration-color 0.18s ease; +} + +main[data-pagefind-body] a:not(.hero-button):not(.home-brand):not(.home-topbar-nav a):not(.docs-navbar-action):not(.docs-nav-link):not(.subheading-anchor):hover, +.nextra-body-typesetting-article a:not(.nextra-card):hover { + text-decoration-color: currentColor; +} + +main[data-pagefind-body] h2 .subheading-anchor, +main[data-pagefind-body] h3 .subheading-anchor { + margin-left: 0.45rem; + color: var(--page-fg-muted); + opacity: 0; + transition: + opacity 0.18s ease, + color 0.18s ease; +} + +main[data-pagefind-body] h2:hover .subheading-anchor, +main[data-pagefind-body] h3:hover .subheading-anchor, +main[data-pagefind-body] .subheading-anchor:focus-visible { + opacity: 1; +} + +main[data-pagefind-body] .subheading-anchor:hover { + color: var(--page-fg); +} + +main[data-pagefind-body] :not(pre) > code, +.nextra-body-typesetting-article :not(pre) > code { + display: inline-block; + padding: 0.14rem 0.38rem; + border: 1px solid var(--surface-border); + border-radius: 6px; + background: var(--surface-highlight); + color: var(--page-fg); + font-size: 0.875em; + line-height: 1.45; + vertical-align: baseline; +} + +.docs-code-block { + margin: 28px 0; + border: 1px solid var(--surface-border); + border-radius: 10px; + background: var(--surface-strong); + overflow: hidden; + position: relative; +} + +.docs-code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 40px; + padding: 0 10px 0 14px; + border-bottom: 1px solid var(--surface-border); + background: var(--surface-muted); +} + +.docs-code-block-meta { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.docs-code-block-title { + display: inline-block; + max-width: min(100%, 32rem); + color: var(--page-fg-soft); + font-size: 0.75rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.docs-code-block-language { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 6px; + border-radius: 6px; + background: var(--surface-highlight); + color: var(--page-fg-muted); + font-size: 0.6875rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.docs-code-block-copy { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: auto; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--page-fg-muted); + cursor: pointer; + transition: + color 0.18s ease, + border-color 0.18s ease, + background-color 0.18s ease, + transform 0.18s ease; +} + +.docs-code-block-copy:hover { + color: var(--page-fg); + border-color: var(--surface-border); + background: var(--surface-highlight); +} + +.docs-code-block-copy:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.25); + outline-offset: 2px; +} + +.docs-code-block-copy svg { + width: 16px; + height: 16px; + flex: 0 0 auto; +} + +.docs-code-block-copy-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.docs-code-block-content { + overflow-x: auto; + background: var(--surface-strong); +} + +.docs-code-block-pre { + margin: 0 !important; + padding: 20px 0 !important; + font-size: 13px !important; + background: transparent !important; + overflow-x: auto; + line-height: 20px !important; +} + +.docs-code-block-pre code { + background: transparent !important; + display: grid; + font-size: 13px !important; + line-height: 20px !important; + white-space: pre; + word-break: normal; + hyphens: none; +} + +.docs-code-block-pre .line { + padding: 0 20px; +} + +.docs-package-manager-tabs { + margin: 28px 0; +} + +.docs-package-manager-tabs .docs-code-block { + margin: 0; +} + +.docs-package-manager-tabs-list { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin: 0 0 12px; + border-bottom: 1px solid var(--surface-border); +} + +.docs-package-manager-tab { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 40px; + padding: 0 2px; + margin-bottom: -1px; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--page-fg-muted); + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + text-transform: lowercase; + cursor: pointer; + transition: + color 0.18s ease, + border-bottom-color 0.18s ease, + transform 0.18s ease; +} + +.docs-package-manager-tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 6px; + min-height: 18px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent-primary) 14%, var(--surface-highlight)); + color: var(--accent-primary); + font-size: 0.625rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + align-self: center; +} + +.docs-package-manager-tab:hover { + color: var(--page-fg); +} + +.docs-package-manager-tab--active { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); +} + +.docs-package-manager-tab:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent-primary) 45%, white); + outline-offset: 2px; +} + +.mermaid-diagram { + margin: 28px 0; + border: 1px solid var(--surface-border); + border-radius: 10px; + background: var(--surface-strong); + overflow: hidden; +} + +.mermaid-diagram__header { + margin: 0; +} + +.mermaid-diagram__canvas { + overflow-x: auto; + padding: 20px; + background: var(--surface-strong); +} + +.mermaid-diagram__canvas > div { + min-width: fit-content; +} + +.mermaid-diagram__canvas svg { + display: block; + max-width: none; + height: auto; +} + +.mermaid-diagram__loading, +.mermaid-diagram__error { + margin: 0; + color: var(--page-fg-muted); + font-size: 0.875rem; +} + +.mermaid-diagram__fallback { + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + white-space: pre; +} + +.mermaid-diagram__error { + padding: 0 20px 20px; +} + +@media (max-width: 640px) { + main[data-pagefind-body], + .nextra-body-typesetting-article { + padding-top: 32px; + padding-bottom: 64px; + } + + main[data-pagefind-body] h1, + .nextra-body-typesetting-article h1 { + font-size: 2rem; + } + + .docs-code-block-header { + padding-right: 10px; + } + + .docs-code-block-copy { + flex: 0 0 auto; + } + + .mermaid-diagram__canvas { + padding: 16px; + } + + .mermaid-diagram__error { + padding: 0 16px 16px; + } +} + +main[data-pagefind-body] pre, +.nextra-body-typesetting-article pre { + padding: 16px !important; + font-size: 0.9rem !important; +} + +main[data-pagefind-body] blockquote, +.nextra-body-typesetting-article blockquote, +.nextra-callout { + margin: 24px 0; + padding: 12px 16px; + border-left: 1px solid var(--surface-border-strong); + background: var(--surface-muted); + color: var(--page-fg-soft); + font-size: 0.875rem; + line-height: 1.7; +} + +main[data-pagefind-body] blockquote p, +.nextra-body-typesetting-article blockquote p, +.nextra-callout p { + font-size: inherit; +} + +.docs-callout { + --callout-accent: var(--surface-border-strong); + --callout-accent-soft: color-mix(in srgb, var(--callout-accent) 16%, transparent); + --callout-accent-strong: color-mix(in srgb, var(--callout-accent) 76%, white 24%); + --callout-symbol: 'i'; + margin: 24px 0; + padding: 12px 14px; + border: 1px solid var(--callout-accent-soft); + border-left-width: 3px; + border-radius: 10px; + background: color-mix(in srgb, var(--surface-muted) 94%, var(--callout-accent) 6%); +} + +.docs-callout__title { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0 0 8px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; + letter-spacing: 0.01em; + color: var(--callout-accent-strong); +} + +.docs-callout__title::before { + content: var(--callout-symbol); + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1px solid color-mix(in srgb, var(--callout-accent) 32%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--callout-accent) 12%, transparent); + color: var(--callout-accent-strong); + font-size: 0.75rem; + font-weight: 700; + line-height: 1; +} + +.docs-callout__content { + color: var(--page-fg-soft); + font-size: 0.875rem; + line-height: 1.65; +} + +.docs-callout__content p { + color: inherit; +} + +.docs-callout > :first-child { + margin-top: 0; +} + +.docs-callout > :last-child { + margin-bottom: 0; +} + +.docs-callout__content > :first-child { + margin-top: 0; +} + +.docs-callout__content > :last-child { + margin-bottom: 0; +} + +.docs-callout--note { + --callout-accent: #60a5fa; + --callout-accent-strong: #93c5fd; + --callout-symbol: 'i'; +} + +.docs-callout--tip { + --callout-accent: #34d399; + --callout-accent-strong: #6ee7b7; + --callout-symbol: '✓'; +} + +.docs-callout--important { + --callout-accent: #a78bfa; + --callout-accent-strong: #c4b5fd; + --callout-symbol: '+'; +} + +.docs-callout--warning { + --callout-accent: #f59e0b; + --callout-accent-strong: #fbbf24; + --callout-symbol: '!'; +} + +.docs-callout--caution { + --callout-accent: #f87171; + --callout-accent-strong: #fca5a5; + --callout-symbol: '!'; +} + +main[data-pagefind-body] table, +.nextra-body-typesetting-article table { + width: 100%; + border-collapse: collapse; + margin: 24px 0 32px; + font-size: 0.9375rem; +} + +main[data-pagefind-body] th, +.nextra-body-typesetting-article th { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--surface-border-strong); + color: var(--page-fg); + font-weight: 600; +} + +main[data-pagefind-body] td, +.nextra-body-typesetting-article td { + padding: 10px 12px; + border-bottom: 1px solid var(--surface-separator); + color: var(--page-fg-soft); +} + +.docs-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.docs-badge--stable { background: rgba(16, 185, 129, 0.1); color: #10b981; } +.docs-badge--beta { background: rgba(245, 158, 11, 0.1); color: #f59e0b; } +.docs-badge--experimental { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; } + +@media (max-width: 768px) { + .home-proof-strip, + .home-link-grid, + .capability-grid, + .reading-path-grid { + grid-template-columns: 1fr; + } + + .home-hero h1 { + font-size: 2.5rem; + } + + .docs-navbar-actions { + display: none; + } + + .home-contributors-shell { + padding: 20px; + } + + .home-contributors-grid { + gap: 12px; + } + + .home-contributor-avatar { + width: 60px; + height: 60px; + } + + .home-site-footer__inner { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/doc/app/home-page.mdx b/doc/app/home-page.mdx index 45f80343..3526e5ef 100644 --- a/doc/app/home-page.mdx +++ b/doc/app/home-page.mdx @@ -1,4 +1,5 @@ import Link from 'next/link' +import {HomeContributors} from '../components/home-contributors' import { capabilityCards, heroProofPoints, @@ -15,7 +16,7 @@ import {