@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 +148 -0
- package/package.json +19 -0
- package/src/api.ts +45 -0
- package/src/bin.js +3 -0
- package/src/cli.ts +57 -0
- package/src/commands/check.ts +48 -0
- package/src/commands/init.ts +175 -0
- package/src/commands/list.ts +59 -0
- package/src/commands/pull.ts +140 -0
- package/src/config.ts +55 -0
- package/src/discover.ts +23 -0
- package/src/parse.ts +45 -0
- package/src/serialize.ts +38 -0
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
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
|
+
}
|
package/src/discover.ts
ADDED
|
@@ -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
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -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
|
+
}
|