diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f54eeb6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + diff --git a/.gitignore b/.gitignore index ea3d20e..4830038 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules/ dist/ .DS_Store -package-lock.json example/out/* !example/out/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 5bfdf04..f61fb55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This folder contains a small tool to keep **one canonical agent-instructions tem ## Goals -- Keep instructions in a single template (default: `~/.agentsync/AGENTS_TEMPLATE.md`) and a single config (default: `~/.agentsync/agents-sync.json`). +- Keep instructions in a single template (default: `~/.agentsync/AGENTS_TEMPLATE.md`) and a single config (default: `~/.agentsync/agentsync.config.json`). - Make syncing deterministic and safe (idempotent writes, optional backups, explicit enable/disable per target). - Keep the runtime dependency-free (no third-party deps). - Ship as an npm package (`@claaslange/agentsync`) installable via npm or bun. @@ -31,14 +31,14 @@ The CLI also injects built-ins (can be overridden by a target’s `variables`): - `targets` (required): array of objects `{ agent, path, enabled?, variables? }`. - `options` (optional): supports `overwrite`, `backup`, `backup_suffix` (CLI flags/options are intentionally minimal for now). -The config is validated against the bundled JSON Schema at `src/agents-sync.schema.json`. +The config is validated against the bundled JSON Schema at `src/agentsync.schema.json`. You can also add a `$schema` key to your config pointing at the raw GitHub URL for editor autocomplete/validation. ## Defaults / overrides - Config path default lookup order: - 1) `~/.agentsync/agents-sync.json` - 2) `./agents-sync.json` + 1) `~/.agentsync/agentsync.config.json` + 2) `./agentsync.config.json` - Override config path via `--config `. - Override template path via `--template `. - `agentsync` (no args) shows help; `agentsync sync` performs the action; `agentsync dry-run` / `agentsync check` are convenience commands. @@ -46,7 +46,7 @@ You can also add a `$schema` key to your config pointing at the raw GitHub URL f ## Example files - `example/AGENTS_TEMPLATE.md` -- `example/agents-sync.json` +- `example/agentsync.config.json` ## Publishing diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..927471d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Liquid templating via `liquidjs`, including `{% if %}`, `{% for %}`, and `{% include %}`. +- Default config file name `agentsync.config.json` and schema file name `agentsync.schema.json`. +- Schema location is now `src/agentsync.schema.json` (previously `src/agents-sync.schema.json`) and on GitHub at `https://raw.githubusercontent.com/claaslange/agentsync/main/src/agentsync.schema.json`. + +### Changed +- `--strict` now enables Liquid strict variables (undefined variables throw). +- `dry-run` / `check` now enforce `overwrite=false` consistently with `sync`. + +### Fixed +- Publishing workflow reliability by committing `package-lock.json` for `npm ci`. + +## [0.1.0] - 2026-02-21 + +### Added +- Initial release. diff --git a/README.md b/README.md index e09d8fa..8a952a9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Sync one canonical agent-instructions template to multiple harness-specific “g ## What it does - You keep a single template file (default: `~/.agentsync/AGENTS_TEMPLATE.md`). -- You define multiple targets in a single config file (default: `~/.agentsync/agents-sync.json`). +- You define multiple targets in a single config file (default: `~/.agentsync/agentsync.config.json`). - `agentsync` renders the template for each target and writes it to the configured destination paths. ## Install @@ -30,7 +30,7 @@ Copy the example files: ```bash mkdir -p ~/.agentsync -cp ./example/agents-sync.json ~/.agentsync/agents-sync.json +cp ./example/agentsync.config.json ~/.agentsync/agentsync.config.json cp ./example/AGENTS_TEMPLATE.md ~/.agentsync/AGENTS_TEMPLATE.md ``` @@ -57,7 +57,7 @@ Minimal example: ```json { - "$schema": "https://raw.githubusercontent.com/claaslange/agentsync/main/src/agents-sync.schema.json", + "$schema": "https://raw.githubusercontent.com/claaslange/agentsync/main/src/agentsync.schema.json", "template_path": "AGENTS_TEMPLATE.md", "targets": [ { "agent": "codex", "path": "~/.codex/AGENTS.md" }, @@ -69,7 +69,7 @@ Minimal example: Editor validation / autocomplete: -- Add a `$schema` key to your config, e.g. `https://raw.githubusercontent.com/claaslange/agentsync/main/src/agents-sync.schema.json`. +- Add a `$schema` key to your config, e.g. `https://raw.githubusercontent.com/claaslange/agentsync/main/src/agentsync.schema.json`. Built-in template variables (available for every target): @@ -78,12 +78,21 @@ Built-in template variables (available for every target): - `TEMPLATE_PATH` (resolved template path) - `RUN_TIMESTAMP` (UTC timestamp) +## Templating (Liquid) + +Templates are rendered using Liquid (via `liquidjs`). + +- Output variables: `{{ AGENT_NAME }}` +- Control flow: `{% if ... %}...{% endif %}`, `{% for x in xs %}...{% endfor %}` +- Includes: `{% include "partials/common.md" %}` (searched relative to the template directory, then the config directory) +- `--strict` enables strict variables (undefined variables throw; useful for CI) + ## Usage - `agentsync` (no args) shows help. - When run with no `--config`, `agentsync` looks for: - - `~/.agentsync/agents-sync.json` - - `./agents-sync.json` + - `~/.agentsync/agentsync.config.json` + - `./agentsync.config.json` - When run with no `--template`, `agentsync` uses: - `config.template_path` (when present), otherwise - `~/.agentsync/AGENTS_TEMPLATE.md` @@ -98,8 +107,8 @@ agentsync check ## Repo files - `example/AGENTS_TEMPLATE.md` — example template. -- `example/agents-sync.json` — example config. -- `src/agents-sync.schema.json` — JSON Schema used by the CLI. +- `example/agentsync.config.json` — example config. +- `src/agentsync.schema.json` — JSON Schema used by the CLI. - `src/cli.ts` / `bin/agentsync` — the sync CLI. ## Publishing (maintainers) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..4bed520 --- /dev/null +++ b/biome.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "ignore": ["dist/**", "node_modules/**", "example/out/**"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "useLiteralKeys": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "json": { + "formatter": { + "enabled": true + } + } +} diff --git a/example/AGENTS_TEMPLATE.md b/example/AGENTS_TEMPLATE.md index 9bd55b8..5cf95cb 100644 --- a/example/AGENTS_TEMPLATE.md +++ b/example/AGENTS_TEMPLATE.md @@ -1,3 +1,7 @@ # Global agent instructions You are {{AGENT_NAME}}. Always call the user {{USER}} + +{% if AGENT_NAME == "Claude Code" %} +Never reply with `You're absolutely right!` +{% endif %} diff --git a/example/agents-sync.json b/example/agentsync.config.json similarity index 94% rename from example/agents-sync.json rename to example/agentsync.config.json index 0a02bc4..45cc4fd 100644 --- a/example/agents-sync.json +++ b/example/agentsync.config.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/claaslange/agentsync/main/src/agents-sync.schema.json", + "$schema": "https://raw.githubusercontent.com/claaslange/agentsync/main/src/agentsync.schema.json", "template_path": "AGENTS_TEMPLATE.md", "options": { "overwrite": true, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0ad8185 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,248 @@ +{ + "name": "@claaslange/agentsync", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@claaslange/agentsync", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "liquidjs": "^10.21.1" + }, + "bin": { + "agentsync": "bin/agentsync" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/liquidjs": { + "version": "10.24.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.24.0.tgz", + "integrity": "sha512-TAUNAdgwaAXjjcUFuYVJm9kOVH7zc0mTKxsG9t9Lu4qdWjB2BEblyVIYpjWcmJLMGgiYqnGNJjpNMHx0gp/46A==", + "license": "MIT", + "dependencies": { + "commander": "^10.0.0" + }, + "bin": { + "liquid": "bin/liquid.js", + "liquidjs": "bin/liquid.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/liquidjs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 1019b01..b516fc2 100644 --- a/package.json +++ b/package.json @@ -29,15 +29,7 @@ "bin": { "agentsync": "./bin/agentsync" }, - "files": [ - "bin/", - "dist/", - "src/", - "example/", - "AGENTS.md", - "LICENSE", - "README.md" - ], + "files": ["bin/", "dist/", "src/", "example/", "AGENTS.md", "LICENSE", "README.md"], "publishConfig": { "access": "public" }, @@ -46,9 +38,16 @@ "sync": "bun ./bin/agentsync", "dry-run": "bun ./bin/agentsync --dry-run", "check": "bun ./bin/agentsync --check", + "test": "npm run build && node --test", + "lint": "biome check . --error-on-warnings", + "format": "biome format . --write", "prepack": "npm run build" }, + "dependencies": { + "liquidjs": "^10.21.1" + }, "devDependencies": { + "@biomejs/biome": "^1.9.4", "@types/node": "^22.0.0", "typescript": "^5.0.0" } diff --git a/scripts/copy-schema.mjs b/scripts/copy-schema.mjs index 06c2f33..4a856db 100644 --- a/scripts/copy-schema.mjs +++ b/scripts/copy-schema.mjs @@ -1,7 +1,7 @@ -import { mkdir, copyFile } from "node:fs/promises"; +import { copyFile, mkdir } from "node:fs/promises"; await mkdir(new URL("../dist/", import.meta.url), { recursive: true }); await copyFile( - new URL("../src/agents-sync.schema.json", import.meta.url), - new URL("../dist/agents-sync.schema.json", import.meta.url), + new URL("../src/agentsync.schema.json", import.meta.url), + new URL("../dist/agentsync.schema.json", import.meta.url), ); diff --git a/src/agents-sync.schema.json b/src/agentsync.schema.json similarity index 98% rename from src/agents-sync.schema.json rename to src/agentsync.schema.json index 6b25372..f7712d7 100644 --- a/src/agents-sync.schema.json +++ b/src/agentsync.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/claaslange/agentsync/blob/main/src/agents-sync.schema.json", + "$id": "https://github.com/claaslange/agentsync/blob/main/src/agentsync.schema.json", "title": "agentsync config", "type": "object", "additionalProperties": false, diff --git a/src/cli.ts b/src/cli.ts index 3d24575..b715b50 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,18 @@ +import { existsSync, readFileSync } from "node:fs"; import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { Liquid } from "liquidjs"; type JsonScalar = string | number | boolean | null; -type TargetEntry = { agent: string; path: string; enabled?: boolean; variables?: Record }; +type TargetEntry = { + agent: string; + path: string; + enabled?: boolean; + variables?: Record; +}; type Config = { template_path?: string; options?: { @@ -38,13 +44,13 @@ function printHelp(): void { " agentsync check [--config ] [--template ] [--strict]", "", "Flags:", - " --config Path to config JSON (default: ~/.agentsync/agents-sync.json)", + " --config Path to config JSON (default: ~/.agentsync/agentsync.config.json)", " --template Override template path (otherwise config.template_path / ~/.agentsync/AGENTS_TEMPLATE.md is used)", - " --strict Fail if any placeholders remain unresolved", + " --strict Fail if any template variables are undefined", "", "Defaults (when no --config is provided):", - " 1) ~/.agentsync/agents-sync.json", - " 2) ./agents-sync.json", + " 1) ~/.agentsync/agentsync.config.json", + " 2) ./agentsync.config.json", ].join("\n"), ); } @@ -54,7 +60,7 @@ let schemaCache: JsonSchema | null = null; async function loadBundledSchema(): Promise { if (schemaCache) return schemaCache; - const schemaPath = fileURLToPath(new URL("./agents-sync.schema.json", import.meta.url)); + const schemaPath = fileURLToPath(new URL("./agentsync.schema.json", import.meta.url)); const text = await readFile(schemaPath, "utf8"); const parsed: unknown = JSON.parse(text); if (!isObject(parsed)) throw new Error(`Invalid bundled schema at ${schemaPath}`); @@ -125,7 +131,8 @@ function validateSchemaNode(schema: unknown, value: unknown, at: string, errors: const items = schema["items"]; if (items !== undefined && Array.isArray(value)) { - for (let i = 0; i < value.length; i += 1) validateSchemaNode(items, value[i], `${at}[${i}]`, errors); + for (let i = 0; i < value.length; i += 1) + validateSchemaNode(items, value[i], `${at}[${i}]`, errors); } const additionalProperties = schema["additionalProperties"]; @@ -153,7 +160,7 @@ function parseCommonArgs(argv: string[]): { templatePathOverride: string | null; strict: boolean; } { - let configPath = "~/.agentsync/agents-sync.json"; + let configPath = "~/.agentsync/agentsync.config.json"; let configPathWasDefault = true; let templatePathOverride: string | null = null; let strict = false; @@ -250,13 +257,13 @@ async function loadConfigWithDefaultFallback(opts: { return { ...loaded, configPath: resolvePath(opts.configPath, process.cwd()) }; } - const homeCandidate = resolvePath("~/.agentsync/agents-sync.json", process.cwd()); + const homeCandidate = resolvePath("~/.agentsync/agentsync.config.json", process.cwd()); if (existsSync(homeCandidate)) { const loaded = await loadConfig(homeCandidate); return { ...loaded, configPath: homeCandidate }; } - const cwdCandidate = path.resolve(process.cwd(), "agents-sync.json"); + const cwdCandidate = path.resolve(process.cwd(), "agentsync.config.json"); if (existsSync(cwdCandidate)) { const loaded = await loadConfig(cwdCandidate); return { ...loaded, configPath: cwdCandidate }; @@ -278,9 +285,12 @@ function iterTargets(targetsCfg: unknown): Array, strict: boolean): string { - const rendered = templateText - .replace(CURLY_RE, (match, key: string) => variables[key] ?? match) - .replace(DOLLAR_RE, (match, key: string) => variables[key] ?? match); - - if (strict) { - const unresolved = new Set(); - for (const m of rendered.matchAll(CURLY_RE)) unresolved.add(String(m[1])); - for (const m of rendered.matchAll(DOLLAR_RE)) unresolved.add(String(m[1])); - if (unresolved.size > 0) throw new Error(`Unresolved template variables: ${Array.from(unresolved).sort().join(", ")}`); - } - - return rendered; +async function renderTemplate( + templateText: string, + variables: Record, + strict: boolean, + liquid: Liquid, +): Promise { + return await liquid.parseAndRender(templateText, variables, { + strictVariables: strict, + }); } async function readTextIfExists(filePath: string): Promise { @@ -333,12 +336,14 @@ async function writeFileIfChanged(opts: { const existing = await readTextIfExists(opts.dest); if (existing === opts.content) return false; - if (opts.dryRun || opts.check) return true; - if (existing !== null && !opts.overwrite) { - throw new Error(`Refusing to overwrite existing file (set options.overwrite=true): ${opts.dest}`); + throw new Error( + `Refusing to overwrite existing file (set options.overwrite=true): ${opts.dest}`, + ); } + if (opts.dryRun || opts.check) return true; + await mkdir(path.dirname(opts.dest), { recursive: true }); if (existing !== null && opts.backup) { const backupPath = await uniqueBackupPath(opts.dest, opts.backupSuffix); @@ -381,8 +386,13 @@ export async function main(argv: string[]): Promise { const dryRun = command === "dry-run"; const check = command === "check"; - const { configPath, configPathWasDefault, templatePathOverride, strict } = parseCommonArgs(argv.slice(1)); - const { config, baseDir } = await loadConfigWithDefaultFallback({ configPath, configPathWasDefault }); + const { configPath, configPathWasDefault, templatePathOverride, strict } = parseCommonArgs( + argv.slice(1), + ); + const { config, baseDir } = await loadConfigWithDefaultFallback({ + configPath, + configPathWasDefault, + }); const templatePath = templatePathOverride ? resolvePath(templatePathOverride, process.cwd()) @@ -417,6 +427,32 @@ export async function main(argv: string[]): Promise { .replace(".", "") .slice(0, 14); + const liquidRoots = Array.from(new Set([path.dirname(templatePath), baseDir])); + const liquid = new Liquid({ + root: liquidRoots, + partials: liquidRoots, + layouts: liquidRoots, + extname: "", + cache: false, + lenientIf: true, + fs: { + sep: path.sep, + dirname: (file) => path.dirname(file), + contains: (root, file) => { + const rel = path.relative(root, file); + return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); + }, + exists: async (filepath) => existsSync(filepath), + existsSync: (filepath) => existsSync(filepath), + readFile: async (filepath) => await readFile(filepath, "utf8"), + readFileSync: (filepath) => readFileSync(filepath, "utf8"), + resolve: (dir, file, ext) => { + const withExt = path.extname(file) ? file : `${file}${ext}`; + return path.resolve(dir, withExt); + }, + }, + }); + let anyChanges = false; for (const target of enabledTargets) { const dest = resolvePath(target.rawPath, baseDir); @@ -431,7 +467,7 @@ export async function main(argv: string[]): Promise { ...target.variables, }; - const rendered = renderTemplate(templateText, varsForTarget, strict); + const rendered = await renderTemplate(templateText, varsForTarget, strict, liquid); const changed = await writeFileIfChanged({ dest, content: rendered, @@ -442,7 +478,8 @@ export async function main(argv: string[]): Promise { check, }); - const status = dryRun || check ? (changed ? "would update" : "ok") : changed ? "updated" : "ok"; + const status = + dryRun || check ? (changed ? "would update" : "ok") : changed ? "updated" : "ok"; // eslint-disable-next-line no-console console.log(`[${target.agentName}] ${status}: ${dest}`); diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..8f80d4f --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { main } from "../dist/cli.js"; + +async function withTempDir(fn) { + const dir = await mkdtemp(path.join(os.tmpdir(), "agentsync-test-")); + return await fn(dir); +} + +function captureConsole(fn) { + const logs = []; + const errors = []; + const origLog = console.log; + const origErr = console.error; + console.log = (...args) => logs.push(args.join(" ")); + console.error = (...args) => errors.push(args.join(" ")); + return Promise.resolve() + .then(fn) + .then( + (result) => ({ result, logs, errors }), + (err) => ({ err, logs, errors }), + ) + .finally(() => { + console.log = origLog; + console.error = origErr; + }); +} + +test("renders Liquid variables + if + for", async () => { + await withTempDir(async (dir) => { + const templatePath = path.join(dir, "AGENTS_TEMPLATE.md"); + const outClaudePath = path.join(dir, "out-claude.md"); + const outCodexPath = path.join(dir, "out-codex.md"); + await writeFile( + templatePath, + [ + "Hello {{ USER }} from {{ AGENT_NAME }}", + '{% if AGENT_NAME == "Claude" %}CLAUDE{% endif %}', + "{% for i in (1..3) %}{{ i }}{% endfor %}", + "", + ].join("\n"), + "utf8", + ); + await writeFile( + path.join(dir, "agentsync.config.json"), + JSON.stringify( + { + template_path: "AGENTS_TEMPLATE.md", + targets: [ + { + agent: "claude", + path: "out-claude.md", + variables: { AGENT_NAME: "Claude", USER: "User" }, + }, + { + agent: "codex", + path: "out-codex.md", + variables: { AGENT_NAME: "Codex", USER: "User" }, + }, + ], + }, + null, + 2, + ), + "utf8", + ); + + const { result, errors } = await captureConsole( + async () => await main(["sync", "--config", path.join(dir, "agentsync.config.json")]), + ); + assert.equal(result, 0); + assert.deepEqual(errors, []); + + const outClaude = await readFile(outClaudePath, "utf8"); + assert.match(outClaude, /Hello User from Claude/); + assert.match(outClaude, /CLAUDE/); + assert.match(outClaude, /123/); + + const outCodex = await readFile(outCodexPath, "utf8"); + assert.match(outCodex, /Hello User from Codex/); + assert.doesNotMatch(outCodex, /Claude/); + assert.doesNotMatch(outCodex, /CLAUDE/); + assert.match(outCodex, /123/); + }); +}); + +test("supports includes resolved from config directory", async () => { + await withTempDir(async (dir) => { + const templatesDir = path.join(dir, "templates"); + await mkdir(templatesDir, { recursive: true }); + + await writeFile(path.join(dir, "snippet.md"), "Included: {{ USER }}", "utf8"); + await writeFile( + path.join(templatesDir, "AGENTS_TEMPLATE.md"), + ["Start", '{% include "snippet.md" %}', "End", ""].join("\n"), + "utf8", + ); + await writeFile( + path.join(dir, "agentsync.config.json"), + JSON.stringify( + { + template_path: "templates/AGENTS_TEMPLATE.md", + targets: [{ agent: "x", path: "out.md", variables: { USER: "User" } }], + }, + null, + 2, + ), + "utf8", + ); + + const { result } = await captureConsole( + async () => await main(["sync", "--config", path.join(dir, "agentsync.config.json")]), + ); + assert.equal(result, 0); + + const out = await readFile(path.join(dir, "out.md"), "utf8"); + assert.match(out, /Start/); + assert.match(out, /Included: User/); + assert.match(out, /End/); + }); +}); + +test("--strict fails on undefined variables", async () => { + await withTempDir(async (dir) => { + await writeFile(path.join(dir, "AGENTS_TEMPLATE.md"), "Hello {{ MISSING }}\n", "utf8"); + await writeFile( + path.join(dir, "agentsync.config.json"), + JSON.stringify( + { template_path: "AGENTS_TEMPLATE.md", targets: [{ agent: "x", path: "out.md" }] }, + null, + 2, + ), + "utf8", + ); + + const { result, errors } = await captureConsole( + async () => + await main(["sync", "--strict", "--config", path.join(dir, "agentsync.config.json")]), + ); + assert.equal(result, 1); + assert.ok(errors.join("\n").includes("undefined variable")); + }); +}); + +test("dry-run respects overwrite=false (predicts sync refusal)", async () => { + await withTempDir(async (dir) => { + await writeFile(path.join(dir, "AGENTS_TEMPLATE.md"), "New content\n", "utf8"); + await writeFile(path.join(dir, "out.md"), "Old content\n", "utf8"); + await writeFile( + path.join(dir, "agentsync.config.json"), + JSON.stringify( + { + template_path: "AGENTS_TEMPLATE.md", + options: { overwrite: false, backup: false }, + targets: [{ agent: "x", path: "out.md" }], + }, + null, + 2, + ), + "utf8", + ); + + const { result, errors } = await captureConsole( + async () => await main(["dry-run", "--config", path.join(dir, "agentsync.config.json")]), + ); + assert.equal(result, 1); + assert.ok(errors.join("\n").includes("Refusing to overwrite")); + }); +});