@dittowords/spec-cli 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # @dittowords/spec-cli
2
+
3
+ > **Alpha** — this package is in early development. The format and CLI interface may change between releases.
4
+
5
+ CLI for syncing `.ditto.md` content specs with the Ditto platform.
6
+
7
+ A `.ditto.md` file lives next to a component and declares its **text surfaces** — the props (and `children`) that hold user-facing copy. The file is pure metadata; nothing imports it at runtime. It exists for three consumers:
8
+
9
+ - **Agents** read it as fast-path context when writing or editing copy for the component.
10
+ - **The CLI** (`ditto-spec pull`) syncs style guide rules from the platform whose tags match the file's surface tags.
11
+ - **Humans** review content decisions in PRs.
12
+
13
+ ## File format
14
+
15
+ Everything lives in YAML frontmatter. The markdown body below the closing `---` is unused.
16
+
17
+ ### Component spec (`<Component>/index.ditto.md`)
18
+
19
+ ```yaml
20
+ ---
21
+ component: DialogueModal
22
+ description: >
23
+ A two-button confirmation modal for actions that need explicit
24
+ acknowledgement. Used for routine confirms and destructive flows.
25
+ tags: [dialog, confirmation]
26
+ surfaces:
27
+ headline:
28
+ tags: [heading, dialog-title]
29
+ maxLength: 60
30
+ content:
31
+ tags: [body, dialog-body]
32
+ maxLength: 240
33
+ actionText:
34
+ tags: [call-to-action]
35
+ maxLength: 25
36
+ cancelText:
37
+ tags: [button]
38
+ maxLength: 25
39
+ # Managed by Ditto — do not edit below
40
+ rules:
41
+ - name: Confirmation dialogs should be direct
42
+ description: Keep confirmation copy terse and unambiguous
43
+ - surface: actionText
44
+ name: Calls to action should use active voice
45
+ description: Always lead with a verb
46
+ examples:
47
+ - from: "Your settings"
48
+ to: "Open settings"
49
+ examples: []
50
+ ---
51
+ ```
52
+
53
+ ### Workspace spec (`workspace.ditto.md`)
54
+
55
+ A repo may have a single `workspace.ditto.md` somewhere under the CLI's configured `roots`. It holds universal rules from the workspace style guide that carry no tags — these apply to every surface in every component.
56
+
57
+ ```yaml
58
+ ---
59
+ workspace: true
60
+ description: >
61
+ Workspace-wide content rules. Read alongside any component's index.ditto.md.
62
+ # Managed by Ditto — do not edit below
63
+ rules: []
64
+ ---
65
+ ```
66
+
67
+ ### Key concepts
68
+
69
+ **Developer-owned keys**: `component`, `description`, `tags`, `surfaces`. Edit these freely.
70
+
71
+ **CLI-managed keys**: `rules`, `examples`. Overwritten by `ditto-spec pull`. Do not edit by hand.
72
+
73
+ **Surface keys** are prop paths on the component. Dot-notation works for nested props (e.g., `primaryAction.label`). Use `$children` for components whose text comes through the `children` prop.
74
+
75
+ **Component-level `tags`** cause matching rules to apply to every surface in the component (emitted in `rules` with no `surface` field). Per-surface tags cause rules to emit with `surface: "<key>"`. If a rule matches both levels, it emits once at component level (broader scope wins).
76
+
77
+ **`maxLength` / `minLength`** are hard layout constraints, not stylistic preferences. Stylistic guidance belongs on the platform as rules.
78
+
79
+ ### Three-level rule hierarchy
80
+
81
+ | Scope | Where | Applies to |
82
+ |---|---|---|
83
+ | **Workspace** | `workspace.ditto.md` `rules[]` | Every surface in every component |
84
+ | **Component-level** | Component's `rules[]`, no `surface` field | Every surface in this component |
85
+ | **Per-surface** | Component's `rules[]`, with `surface: "<key>"` | That one surface |
86
+
87
+ ## CLI commands
88
+
89
+ ### `ditto-spec init`
90
+
91
+ First-time setup. Creates `dittospec.config.json` and `workspace.ditto.md` in the current directory, then detects your agent environment (Claude Code, Cursor) and prints setup suggestions.
92
+
93
+ Use `--agent` to also write agent configuration directly: appends a Ditto Content Specs section to `CLAUDE.md` or `.cursorrules` (creates the file if absent).
94
+
95
+ ### `ditto-spec pull`
96
+
97
+ Syncs rules from the platform into spec files.
98
+
99
+ 1. Discovers all `.ditto.md` files under configured roots
100
+ 2. Fetches all workspace rules from `GET /v2/rules/mcp`
101
+ 3. Matches rules to specs by tag intersection (client-side)
102
+ 4. Rewrites the `rules` key in each file's YAML frontmatter
103
+
104
+ Use `--dry-run` to see what would change without writing.
105
+
106
+ ### `ditto-spec check`
107
+
108
+ Validates all spec files: YAML parses correctly, required keys are present, surfaces have `tags` arrays. Exits non-zero on any failure — useful for CI.
109
+
110
+ ### `ditto-spec list`
111
+
112
+ Prints an inventory of all component specs with their surfaces, tags, and constraints.
113
+
114
+ ## Configuration
115
+
116
+ Create `dittospec.config.json` at your repo root (or any ancestor directory):
117
+
118
+ ```json
119
+ {
120
+ "apiBase": "https://api.dittowords.com",
121
+ "workspaceId": "your-workspace-id",
122
+ "roots": ["design-system"]
123
+ }
124
+ ```
125
+
126
+ | Key | Description |
127
+ |---|---|
128
+ | `apiBase` | Ditto API base URL |
129
+ | `workspaceId` | Your workspace ID |
130
+ | `roots` | Repo-relative directories to search for `.ditto.md` files. Defaults to `["."]`. |
131
+
132
+ Set `DITTO_API_KEY` in your environment or in a `.env` file at the repo root.
133
+
134
+ ## Agent contract
135
+
136
+ When writing or editing text props for a component:
137
+
138
+ 1. Read `workspace.ditto.md` (if it exists) for universal rules.
139
+ 2. Read the component's `index.ditto.md`. Match each prop you're filling to a surface key.
140
+ 3. Respect `maxLength` — it's a layout invariant, not a suggestion.
141
+ 4. Follow all rules in `rules[]`. Entries without `surface` apply to every surface; entries with `surface` apply only to that surface.
142
+ 5. Reference `examples[]` entries with `status: "approved"` as concrete tone/shape guidance.
143
+
144
+ ### Creating specs
145
+
146
+ When scaffolding a new design-system component with text-bearing props, create an `index.ditto.md` alongside it. Declare a surface for each prop that holds user-facing copy. Tag surfaces with relevant content categories (e.g., `heading`, `body`, `button`, `cta`) so platform rules propagate on the next `pull`.
147
+
148
+ If a component lacks a spec and you'd have found one useful, propose creating one. If a spec lacks a rule you'd have wanted, propose adding the rule to the platform style guide with appropriate tags.
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@dittowords/spec-cli",
3
+ "version": "0.0.1-alpha.0",
4
+ "description": "CLI for syncing .ditto.md content specs with the Ditto platform.",
5
+ "main": "src/cli.ts",
6
+ "bin": {
7
+ "ditto-spec": "src/bin.js"
8
+ },
9
+ "scripts": {
10
+ "ditto-spec": "tsx src/cli.ts"
11
+ },
12
+ "dependencies": {
13
+ "dotenv": "^16.0.0",
14
+ "glob": "^11.0.1",
15
+ "js-yaml": "^4.1.0",
16
+ "tsx": "^4.0.0"
17
+ },
18
+ "license": "Proprietary"
19
+ }
package/src/api.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { Config } from "./config";
2
+
3
+ export interface RuleResponse {
4
+ name: string;
5
+ type: "style" | "wordlist";
6
+ description: string;
7
+ examples: { from: string; to: string }[];
8
+ tags: string[];
9
+ }
10
+
11
+ export interface GetRulesMCPResponse {
12
+ workspaceRules: RuleResponse[];
13
+ projectRules: Record<string, RuleResponse[]>;
14
+ }
15
+
16
+ export class DittoApi {
17
+ constructor(private readonly config: Config, private readonly apiKey: string) {}
18
+
19
+ async getRules(): Promise<RuleResponse[]> {
20
+ const url = new URL("/v2/rules/mcp", this.config.apiBase);
21
+ const res = await this.fetch(url, { method: "GET" });
22
+ const data = (await res.json()) as GetRulesMCPResponse;
23
+ return data.workspaceRules;
24
+ }
25
+
26
+ private async fetch(url: URL, init: RequestInit): Promise<Response> {
27
+ const res = await fetch(url.toString(), {
28
+ ...init,
29
+ headers: { ...this.headers(), ...(init.headers ?? {}) },
30
+ });
31
+ if (!res.ok) {
32
+ const body = await res.text();
33
+ throw new Error(`${init.method} ${url} → ${res.status}: ${body}`);
34
+ }
35
+ return res;
36
+ }
37
+
38
+ private headers(): Record<string, string> {
39
+ return {
40
+ authorization: this.apiKey,
41
+ workspace_id: this.config.workspaceId,
42
+ "content-type": "application/json",
43
+ };
44
+ }
45
+ }
package/src/bin.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ require("tsx/cjs");
3
+ require("./cli.ts");
package/src/cli.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { check } from "./commands/check";
2
+ import { init } from "./commands/init";
3
+ import { list } from "./commands/list";
4
+ import { pull } from "./commands/pull";
5
+
6
+ const COMMANDS: Record<string, (args: string[]) => Promise<void>> = {
7
+ init: async (args) => init({ writeAgent: args.includes("--agent") }),
8
+ pull: async (args) => pull({ dryRun: args.includes("--dry-run") }),
9
+ check: async () => check(),
10
+ list: async () => list(),
11
+ };
12
+
13
+ const HELP = `ditto-spec — sync .ditto.md content specs with the Ditto platform.
14
+
15
+ Usage:
16
+ ditto-spec <command> [options]
17
+
18
+ Commands:
19
+ init Set up ditto specs: creates config, workspace spec, and prints agent setup.
20
+ init --agent Also writes agent configuration (CLAUDE.md, .cursorrules, etc.).
21
+ pull Pull rules from the platform into the managed keys of each spec.
22
+ pull --dry-run Show which files would change without writing.
23
+ check Parse every spec file; exit non-zero on any malformed file.
24
+ list Print every component spec with its surfaces and tags.
25
+
26
+ Environment:
27
+ DITTO_API_KEY Workspace API key (required for pull).
28
+
29
+ Config:
30
+ Reads dittospec.config.json from the nearest ancestor directory:
31
+ { "apiBase": "https://...", "workspaceId": "...", "roots": ["design-system"] }
32
+ `;
33
+
34
+ async function main() {
35
+ const args = process.argv.slice(2);
36
+ const cmd = args[0];
37
+
38
+ if (!cmd || cmd === "--help" || cmd === "-h") {
39
+ process.stdout.write(HELP);
40
+ return;
41
+ }
42
+
43
+ const handler = COMMANDS[cmd];
44
+ if (!handler) {
45
+ process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}`);
46
+ process.exit(2);
47
+ }
48
+
49
+ try {
50
+ await handler(args.slice(1));
51
+ } catch (err) {
52
+ console.error(err instanceof Error ? err.message : err);
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ main();
@@ -0,0 +1,48 @@
1
+ import path from "path";
2
+ import { loadConfig } from "../config";
3
+ import { discover } from "../discover";
4
+ import { parseSpecFile } from "../parse";
5
+
6
+ export async function check(): Promise<void> {
7
+ const { config, root } = loadConfig();
8
+ const files = discover(root, config.roots);
9
+
10
+ if (files.length === 0) {
11
+ console.log("No .ditto.md files found.");
12
+ return;
13
+ }
14
+
15
+ let failed = 0;
16
+ for (const f of files) {
17
+ try {
18
+ const parsed = parseSpecFile(f.abs);
19
+ const specLike = parsed.spec as Record<string, unknown>;
20
+
21
+ if (parsed.kind === "component") {
22
+ const surfaces = specLike.surfaces;
23
+ if (!surfaces || typeof surfaces !== "object") {
24
+ throw new Error("component spec missing 'surfaces' object");
25
+ }
26
+ for (const [key, val] of Object.entries(surfaces as Record<string, unknown>)) {
27
+ if (!val || typeof val !== "object") {
28
+ throw new Error(`surface '${key}' must be an object`);
29
+ }
30
+ const surface = val as Record<string, unknown>;
31
+ if (!Array.isArray(surface.tags)) {
32
+ throw new Error(`surface '${key}' missing 'tags' array`);
33
+ }
34
+ }
35
+ }
36
+
37
+ console.log(`✓ ${path.relative(root, f.abs)}`);
38
+ } catch (err) {
39
+ failed++;
40
+ console.error(`× ${path.relative(root, f.abs)}: ${err instanceof Error ? err.message : err}`);
41
+ }
42
+ }
43
+
44
+ if (failed > 0) {
45
+ console.error(`\n${failed} file(s) failed validation.`);
46
+ process.exit(1);
47
+ }
48
+ }
@@ -0,0 +1,175 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const CONFIG_NAME = "dittospec.config.json";
5
+ const WORKSPACE_SPEC_NAME = "workspace.ditto.md";
6
+
7
+ const DEFAULT_CONFIG = JSON.stringify(
8
+ {
9
+ apiBase: "https://api.dittowords.com",
10
+ workspaceId: "TODO: your Ditto workspace ID",
11
+ roots: ["."],
12
+ },
13
+ null,
14
+ 2
15
+ );
16
+
17
+ const WORKSPACE_SPEC = `---
18
+ workspace: true
19
+ description: >
20
+ Workspace-wide content rules. Read alongside any component's index.ditto.md.
21
+ # Managed by Ditto — do not edit below
22
+ rules: []
23
+ ---
24
+ `;
25
+
26
+ const CLAUDE_MD_BLOCK = `
27
+ ## Ditto Content Specs
28
+
29
+ This project uses \`.ditto.md\` files for component content governance.
30
+
31
+ When writing or editing text props for a component:
32
+
33
+ 1. Check for \`workspace.ditto.md\` at the project root for universal content rules.
34
+ 2. Check for \`index.ditto.md\` next to the component for surface-specific rules and constraints.
35
+ 3. Respect \`maxLength\` — it's a layout constraint, not a suggestion.
36
+ 4. Follow all rules in the \`rules\` key. Rules without a \`surface\` field apply to every surface; rules with \`surface\` apply only to that surface.
37
+ 5. When creating a new component with text-bearing props, scaffold an \`index.ditto.md\` alongside it.
38
+
39
+ Run \`ditto-spec list\` to see all specs, or read the [spec format docs](./packages/ditto-spec-cli/README.md).
40
+ `;
41
+
42
+ const CURSOR_RULES_BLOCK = `
43
+ # Ditto Content Specs
44
+
45
+ This project uses .ditto.md files for component content governance.
46
+
47
+ When writing or editing text props for a component:
48
+
49
+ 1. Check for workspace.ditto.md at the project root for universal content rules.
50
+ 2. Check for index.ditto.md next to the component for surface-specific rules and constraints.
51
+ 3. Respect maxLength — it's a layout constraint, not a suggestion.
52
+ 4. Follow all rules in the rules key. Rules without a surface field apply to every surface; rules with surface apply only to that surface.
53
+ 5. When creating a new component with text-bearing props, scaffold an index.ditto.md alongside it.
54
+ `;
55
+
56
+ const AGENTS_MD_ROW =
57
+ "| [Ditto component specs](./packages/ditto-spec-cli/README.md) | `.ditto.md` content specs — read before writing component copy |";
58
+
59
+ interface InitOptions {
60
+ writeAgent?: boolean;
61
+ }
62
+
63
+ type AgentEnv = "claude" | "cursor" | "none";
64
+
65
+ export async function init(opts: InitOptions = {}): Promise<void> {
66
+ const cwd = process.cwd();
67
+
68
+ if (fs.existsSync(path.join(cwd, CONFIG_NAME))) {
69
+ console.log(`${CONFIG_NAME} already exists. Nothing to do.`);
70
+ return;
71
+ }
72
+
73
+ fs.writeFileSync(path.join(cwd, CONFIG_NAME), DEFAULT_CONFIG + "\n");
74
+ console.log(`✓ Created ${CONFIG_NAME}`);
75
+
76
+ fs.writeFileSync(path.join(cwd, WORKSPACE_SPEC_NAME), WORKSPACE_SPEC);
77
+ console.log(`✓ Created ${WORKSPACE_SPEC_NAME}`);
78
+
79
+ const env = detectAgentEnv(cwd);
80
+
81
+ if (opts.writeAgent) {
82
+ writeAgentConfig(cwd, env);
83
+ } else {
84
+ printAgentSuggestions(env);
85
+ }
86
+
87
+ printNextSteps();
88
+ }
89
+
90
+ function detectAgentEnv(cwd: string): AgentEnv {
91
+ if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md"))) {
92
+ return "claude";
93
+ }
94
+ if (fs.existsSync(path.join(cwd, ".cursor")) || fs.existsSync(path.join(cwd, ".cursorrules"))) {
95
+ return "cursor";
96
+ }
97
+ return "none";
98
+ }
99
+
100
+ function writeAgentConfig(cwd: string, env: AgentEnv): void {
101
+ if (env === "claude") {
102
+ const claudeMd = path.join(cwd, "CLAUDE.md");
103
+ if (fs.existsSync(claudeMd)) {
104
+ const existing = fs.readFileSync(claudeMd, "utf8");
105
+ if (existing.includes("## Ditto Content Specs")) {
106
+ console.log(" CLAUDE.md already has Ditto Content Specs section — skipped.");
107
+ } else {
108
+ fs.appendFileSync(claudeMd, CLAUDE_MD_BLOCK);
109
+ console.log("✓ Appended Ditto Content Specs section to CLAUDE.md");
110
+ }
111
+ } else {
112
+ fs.writeFileSync(claudeMd, `# CLAUDE.md${CLAUDE_MD_BLOCK}`);
113
+ console.log("✓ Created CLAUDE.md with Ditto Content Specs section");
114
+ }
115
+
116
+ const agentsMd = path.join(cwd, "AGENTS.md");
117
+ if (fs.existsSync(agentsMd)) {
118
+ const existing = fs.readFileSync(agentsMd, "utf8");
119
+ if (existing.includes("Ditto component specs")) {
120
+ console.log(" AGENTS.md already has Ditto row — skipped.");
121
+ } else {
122
+ fs.appendFileSync(agentsMd, "\n" + AGENTS_MD_ROW + "\n");
123
+ console.log("✓ Appended Ditto row to AGENTS.md");
124
+ }
125
+ }
126
+ return;
127
+ }
128
+
129
+ if (env === "cursor") {
130
+ const cursorrules = path.join(cwd, ".cursorrules");
131
+ if (fs.existsSync(cursorrules)) {
132
+ const existing = fs.readFileSync(cursorrules, "utf8");
133
+ if (existing.includes("Ditto Content Specs")) {
134
+ console.log(" .cursorrules already has Ditto section — skipped.");
135
+ } else {
136
+ fs.appendFileSync(cursorrules, CURSOR_RULES_BLOCK);
137
+ console.log("✓ Appended Ditto section to .cursorrules");
138
+ }
139
+ } else {
140
+ fs.writeFileSync(cursorrules, CURSOR_RULES_BLOCK.trimStart());
141
+ console.log("✓ Created .cursorrules with Ditto section");
142
+ }
143
+ return;
144
+ }
145
+
146
+ console.log("\nNo agent environment detected. See next steps for manual setup.");
147
+ }
148
+
149
+ function printAgentSuggestions(env: AgentEnv): void {
150
+ if (env === "claude") {
151
+ console.log("\nDetected Claude Code environment.");
152
+ console.log("Add this to your CLAUDE.md (or run `ditto-spec init --agent` to do it automatically):\n");
153
+ console.log(CLAUDE_MD_BLOCK.trim());
154
+ return;
155
+ }
156
+
157
+ if (env === "cursor") {
158
+ console.log("\nDetected Cursor environment.");
159
+ console.log("Add this to your .cursorrules (or run `ditto-spec init --agent` to do it automatically):\n");
160
+ console.log(CURSOR_RULES_BLOCK.trim());
161
+ return;
162
+ }
163
+
164
+ console.log("\nTo help your AI agent use ditto specs, add the agent contract from the README");
165
+ console.log("to your project's agent configuration file (CLAUDE.md, .cursorrules, etc.).");
166
+ }
167
+
168
+ function printNextSteps(): void {
169
+ console.log(`
170
+ Next steps:
171
+ 1. Fill in your workspaceId in ${CONFIG_NAME}
172
+ 2. Set DITTO_API_KEY in .env or your shell
173
+ 3. Create your first component spec (index.ditto.md next to a component)
174
+ 4. Run \`ditto-spec pull\` to sync rules from the platform`);
175
+ }
@@ -0,0 +1,59 @@
1
+ import path from "path";
2
+ import { loadConfig } from "../config";
3
+ import { discover } from "../discover";
4
+ import { ParsedSpec, parseSpecFile } from "../parse";
5
+
6
+ interface SurfaceLike {
7
+ tags?: string[];
8
+ maxLength?: number;
9
+ }
10
+
11
+ interface SpecLike {
12
+ tags?: string[];
13
+ surfaces?: Record<string, SurfaceLike>;
14
+ }
15
+
16
+ export async function list(): Promise<void> {
17
+ const { config, root } = loadConfig();
18
+ const files = discover(root, config.roots);
19
+
20
+ if (files.length === 0) {
21
+ console.log("No .ditto.md files found.");
22
+ return;
23
+ }
24
+
25
+ for (const f of files) {
26
+ let parsed: ParsedSpec;
27
+ try {
28
+ parsed = parseSpecFile(f.abs);
29
+ } catch (err) {
30
+ console.error(`× ${path.relative(root, f.abs)}: ${err instanceof Error ? err.message : err}`);
31
+ continue;
32
+ }
33
+
34
+ if (parsed.kind === "workspace") {
35
+ const rules = Array.isArray(parsed.spec.rules) ? parsed.spec.rules : [];
36
+ console.log(`\n[workspace] (${path.relative(root, f.abs)})`);
37
+ console.log(` ${rules.length} rule${rules.length === 1 ? "" : "s"}`);
38
+ continue;
39
+ }
40
+
41
+ console.log(`\n${parsed.name} (${path.relative(root, f.abs)})`);
42
+ const specLike = parsed.spec as SpecLike;
43
+ const componentTags = specLike.tags ?? [];
44
+ if (componentTags.length > 0) {
45
+ console.log(` component tags: [${componentTags.join(", ")}]`);
46
+ }
47
+ const surfaces = specLike.surfaces ?? {};
48
+ const entries = Object.entries(surfaces);
49
+ if (entries.length === 0) {
50
+ console.log(" (no surfaces)");
51
+ continue;
52
+ }
53
+ for (const [key, surface] of entries) {
54
+ const tags = (surface.tags ?? []).join(", ");
55
+ const maxLen = surface.maxLength != null ? ` max ${surface.maxLength}` : "";
56
+ console.log(` ${key.padEnd(28)} [${tags}]${maxLen}`);
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,140 @@
1
+ import path from "path";
2
+ import { DittoApi, RuleResponse } from "../api";
3
+ import { getApiKey, loadConfig } from "../config";
4
+ import { discover, DiscoveredFile } from "../discover";
5
+ import { ParsedSpec, parseSpecFile } from "../parse";
6
+ import { rewriteManagedKeys } from "../serialize";
7
+
8
+ interface SurfaceLike {
9
+ tags?: string[];
10
+ }
11
+
12
+ interface SpecLike {
13
+ tags?: string[];
14
+ surfaces?: Record<string, SurfaceLike>;
15
+ }
16
+
17
+ interface PullOptions {
18
+ dryRun?: boolean;
19
+ }
20
+
21
+ export async function pull(opts: PullOptions = {}): Promise<void> {
22
+ const { config, root } = loadConfig();
23
+ const apiKey = getApiKey();
24
+ const api = new DittoApi(config, apiKey);
25
+
26
+ const files = discover(root, config.roots);
27
+ if (files.length === 0) {
28
+ console.log("No .ditto.md files found.");
29
+ return;
30
+ }
31
+
32
+ console.log(`Found ${files.length} spec file${files.length === 1 ? "" : "s"}.`);
33
+
34
+ type ParsedFile = { file: DiscoveredFile; parsed: ParsedSpec };
35
+ const componentFiles: ParsedFile[] = [];
36
+ const workspaceFiles: ParsedFile[] = [];
37
+
38
+ for (const f of files) {
39
+ let parsed: ParsedSpec;
40
+ try {
41
+ parsed = parseSpecFile(f.abs);
42
+ } catch (err) {
43
+ console.error(`× ${f.rel}: ${err instanceof Error ? err.message : err}`);
44
+ continue;
45
+ }
46
+
47
+ if (parsed.kind === "workspace") {
48
+ workspaceFiles.push({ file: f, parsed });
49
+ } else {
50
+ componentFiles.push({ file: f, parsed });
51
+ }
52
+ }
53
+
54
+ if (workspaceFiles.length > 1) {
55
+ const paths = workspaceFiles.map((w) => path.relative(root, w.file.abs)).join(", ");
56
+ throw new Error(`Found multiple workspace specs (${paths}). Expected at most one.`);
57
+ }
58
+
59
+ const allRules = await api.getRules();
60
+ console.log(`Fetched ${allRules.length} rule${allRules.length === 1 ? "" : "s"} from workspace.`);
61
+
62
+ let written = 0;
63
+ let unchanged = 0;
64
+
65
+ if (workspaceFiles.length === 1) {
66
+ const { file, parsed } = workspaceFiles[0];
67
+ const universalRules = allRules.filter((r) => r.tags.length === 0).map(toRuleObj);
68
+
69
+ if (rulesMatch(parsed.spec.rules, universalRules)) {
70
+ unchanged++;
71
+ } else if (opts.dryRun) {
72
+ console.log(`~ ${path.relative(root, file.abs)} (would write ${universalRules.length} workspace rule(s))`);
73
+ } else {
74
+ rewriteManagedKeys(file.abs, { rules: universalRules });
75
+ written++;
76
+ console.log(`✓ ${path.relative(root, file.abs)} — ${universalRules.length} workspace rule(s)`);
77
+ }
78
+ }
79
+
80
+ for (const { file, parsed } of componentFiles) {
81
+ const specLike = parsed.spec as SpecLike;
82
+ const componentTags = specLike.tags ?? [];
83
+ const surfaces = specLike.surfaces ?? {};
84
+
85
+ const componentMatches = rulesForTags(allRules, componentTags);
86
+ const consumed = new Set<RuleResponse>(componentMatches);
87
+
88
+ const matchedRules: Array<Record<string, unknown>> = [];
89
+ for (const rule of componentMatches) {
90
+ matchedRules.push(toRuleObj(rule));
91
+ }
92
+ for (const [surfaceKey, surface] of Object.entries(surfaces)) {
93
+ const matches = rulesForTags(allRules, surface.tags ?? []).filter((r) => !consumed.has(r));
94
+ for (const rule of matches) {
95
+ matchedRules.push(toSurfaceRuleObj(surfaceKey, rule));
96
+ }
97
+ }
98
+
99
+ if (rulesMatch(parsed.spec.rules, matchedRules)) {
100
+ unchanged++;
101
+ continue;
102
+ }
103
+
104
+ if (opts.dryRun) {
105
+ console.log(`~ ${path.relative(root, file.abs)} (would write ${matchedRules.length} rule(s))`);
106
+ continue;
107
+ }
108
+
109
+ rewriteManagedKeys(file.abs, { rules: matchedRules });
110
+ written++;
111
+ console.log(`✓ ${path.relative(root, file.abs)} — ${matchedRules.length} rule(s)`);
112
+ }
113
+
114
+ console.log(`\nDone. ${written} updated, ${unchanged} unchanged.`);
115
+ }
116
+
117
+ function rulesForTags(rules: RuleResponse[], tags: string[]): RuleResponse[] {
118
+ if (tags.length === 0) return [];
119
+ const tagSet = new Set(tags);
120
+ return rules.filter((r) => r.tags.length > 0 && r.tags.some((t) => tagSet.has(t)));
121
+ }
122
+
123
+ function toRuleObj(rule: RuleResponse): Record<string, unknown> {
124
+ const out: Record<string, unknown> = {
125
+ name: rule.name,
126
+ description: rule.description,
127
+ };
128
+ if (rule.examples.length > 0) {
129
+ out.examples = rule.examples.map((ex) => ({ from: ex.from, to: ex.to }));
130
+ }
131
+ return out;
132
+ }
133
+
134
+ function toSurfaceRuleObj(surface: string, rule: RuleResponse): Record<string, unknown> {
135
+ return { surface, ...toRuleObj(rule) };
136
+ }
137
+
138
+ function rulesMatch(existing: unknown, incoming: unknown): boolean {
139
+ return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
140
+ }
package/src/config.ts ADDED
@@ -0,0 +1,55 @@
1
+ import dotenv from "dotenv";
2
+ import fs from "fs";
3
+ import path from "path";
4
+
5
+ export interface Config {
6
+ apiBase: string;
7
+ workspaceId: string;
8
+ roots?: string[];
9
+ }
10
+
11
+ const DEFAULT_CONFIG_NAME = "dittospec.config.json";
12
+
13
+ export function loadConfig(cwd: string = process.cwd()): { config: Config; root: string } {
14
+ let dir = path.resolve(cwd);
15
+ while (true) {
16
+ const candidate = path.join(dir, DEFAULT_CONFIG_NAME);
17
+ if (fs.existsSync(candidate)) {
18
+ const raw = fs.readFileSync(candidate, "utf8");
19
+ const parsed = JSON.parse(raw);
20
+ validate(parsed, candidate);
21
+ return { config: parsed as Config, root: dir };
22
+ }
23
+ const parent = path.dirname(dir);
24
+ if (parent === dir) break;
25
+ dir = parent;
26
+ }
27
+ throw new Error(`No ${DEFAULT_CONFIG_NAME} found from ${cwd} up to filesystem root.`);
28
+ }
29
+
30
+ function validate(c: unknown, source: string): asserts c is Config {
31
+ if (!c || typeof c !== "object") throw new Error(`${source}: must be a JSON object.`);
32
+ const obj = c as Record<string, unknown>;
33
+ if (typeof obj.apiBase !== "string") throw new Error(`${source}: missing string "apiBase".`);
34
+ if (typeof obj.workspaceId !== "string") throw new Error(`${source}: missing string "workspaceId".`);
35
+ if (obj.roots !== undefined && !Array.isArray(obj.roots)) {
36
+ throw new Error(`${source}: "roots" must be an array of strings if present.`);
37
+ }
38
+ }
39
+
40
+ export function getApiKey(): string {
41
+ try {
42
+ const { root } = loadConfig();
43
+ dotenv.config({ path: path.join(root, ".env") });
44
+ } catch {
45
+ // No config yet — let getApiKey still work for direct env exports.
46
+ }
47
+
48
+ const key = process.env.DITTO_API_KEY;
49
+ if (!key) {
50
+ throw new Error(
51
+ "DITTO_API_KEY is not set. Add it to .env at the repo root, or `export DITTO_API_KEY=...` in your shell."
52
+ );
53
+ }
54
+ return key;
55
+ }
@@ -0,0 +1,23 @@
1
+ import { globSync } from "glob";
2
+ import path from "path";
3
+
4
+ const SPEC_GLOB = "**/*.ditto.md";
5
+
6
+ const IGNORE = ["**/node_modules/**", "**/dist*/**", "**/.git/**", "**/.next/**", "**/coverage/**"];
7
+
8
+ export interface DiscoveredFile {
9
+ abs: string;
10
+ rel: string;
11
+ }
12
+
13
+ export function discover(repoRoot: string, roots: string[] = ["."]): DiscoveredFile[] {
14
+ const patterns = roots.map((r) => path.posix.join(r, SPEC_GLOB));
15
+
16
+ const results = globSync(patterns, {
17
+ cwd: repoRoot,
18
+ ignore: IGNORE,
19
+ absolute: true,
20
+ });
21
+
22
+ return results.map((abs) => ({ abs, rel: path.relative(repoRoot, abs) })).sort((a, b) => a.rel.localeCompare(b.rel));
23
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,45 @@
1
+ import fs from "fs";
2
+ import yaml from "js-yaml";
3
+
4
+ export interface ParsedSpec {
5
+ kind: "component" | "workspace";
6
+ name?: string;
7
+ spec: Record<string, unknown>;
8
+ filePath: string;
9
+ }
10
+
11
+ export class ParseError extends Error {
12
+ constructor(public filePath: string, public reason: string) {
13
+ super(`${filePath}: ${reason}`);
14
+ }
15
+ }
16
+
17
+ export function parseSpecFile(filePath: string): ParsedSpec {
18
+ const source = fs.readFileSync(filePath, "utf8");
19
+ const frontmatter = extractFrontmatter(source, filePath);
20
+ const raw = yaml.load(frontmatter);
21
+
22
+ if (!raw || typeof raw !== "object") {
23
+ throw new ParseError(filePath, "frontmatter must be a YAML object");
24
+ }
25
+
26
+ const spec = raw as Record<string, unknown>;
27
+
28
+ if (spec.workspace === true) {
29
+ return { kind: "workspace", spec, filePath };
30
+ }
31
+
32
+ if (typeof spec.component !== "string" || !spec.component) {
33
+ throw new ParseError(filePath, "missing required 'component' key (string)");
34
+ }
35
+
36
+ return { kind: "component", name: spec.component, spec, filePath };
37
+ }
38
+
39
+ function extractFrontmatter(source: string, filePath: string): string {
40
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
41
+ if (!match) {
42
+ throw new ParseError(filePath, "no YAML frontmatter found (expected --- delimiters)");
43
+ }
44
+ return match[1];
45
+ }
@@ -0,0 +1,38 @@
1
+ import fs from "fs";
2
+ import yaml from "js-yaml";
3
+
4
+ const MANAGED_COMMENT = "# Managed by Ditto — do not edit below";
5
+
6
+ export function rewriteManagedKeys(filePath: string, updates: { rules?: unknown[] }): void {
7
+ const source = fs.readFileSync(filePath, "utf8");
8
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
9
+ if (!match) throw new Error(`${filePath}: no YAML frontmatter found`);
10
+
11
+ const spec = yaml.load(match[1]) as Record<string, unknown>;
12
+
13
+ if (updates.rules !== undefined) spec.rules = updates.rules;
14
+
15
+ let dumped = yaml
16
+ .dump(spec, {
17
+ lineWidth: -1,
18
+ noRefs: true,
19
+ sortKeys: false,
20
+ quotingType: '"',
21
+ })
22
+ .trimEnd();
23
+
24
+ dumped = insertManagedComment(dumped);
25
+
26
+ const afterFrontmatter = source.slice(match[0].length);
27
+ fs.writeFileSync(filePath, `---\n${dumped}\n---${afterFrontmatter}`);
28
+ }
29
+
30
+ function insertManagedComment(dumped: string): string {
31
+ const rulesIdx = dumped.search(/^rules:/m);
32
+ if (rulesIdx === -1) return dumped;
33
+
34
+ const before = dumped.slice(0, rulesIdx);
35
+ if (before.includes(MANAGED_COMMENT)) return dumped;
36
+
37
+ return before + MANAGED_COMMENT + "\n" + dumped.slice(rulesIdx);
38
+ }