Skip to content

Commit 0a8b3cb

Browse files
lbb00cursoragent
andauthored
feat(config): add sourceDir object format for one-source-multi-target sync (#35)
- Support { dir, sourceFile, targetFile } in sourceDir for claude.md - Enables common/AGENTS.md to sync as both AGENTS.md and CLAUDE.md - Add getSourceFileOverride/getTargetFileOverride in project-config - Extend claude-md adapter resolveSource with sourceFileOverride - Add ResolvedSource.targetName for target filename override - Update GitRepoSource and DotfileManager to pass/use overrides - Add tests and documentation Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 7011f02 commit 0a8b3cb

12 files changed

Lines changed: 303 additions & 20 deletions

File tree

KNOWLEDGE_BASE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,11 @@ interface ProjectConfig {
397397
- File-based synchronization for CLAUDE.md-style configuration files.
398398
- Supports `.md` suffix; resolves `CLAUDE` → `CLAUDE.md` automatically.
399399
- **User Mode**: `ais claude md add CLAUDE --user` links to `~/.claude/CLAUDE.md`.
400+
- **sourceDir object format**: Use `common/AGENTS.md` as CLAUDE.md source (one file, multiple targets):
401+
```json
402+
{ "sourceDir": { "claude": { "md": { "dir": "common", "sourceFile": "AGENTS.md", "targetFile": "CLAUDE.md" } } } }
403+
```
404+
Enables syncing the same file to both AGENTS.md (via agentsMd) and CLAUDE.md (via claude-md).
400405

401406
### 9. Trae Rule Synchronization
402407
- **Syntax**: `ais trae rules add <ruleName> [alias]`
@@ -687,6 +692,10 @@ ais config repo list
687692
}
688693
```
689694

695+
**sourceDir value formats:**
696+
- **String** (legacy): `"claude": { "md": ".claude" }`source directory path
697+
- **Object**: `"claude": { "md": { "dir": "common", "sourceFile": "AGENTS.md", "targetFile": "CLAUDE.md" } }`use a different source file and target filename (e.g. map `common/AGENTS.md``.claude/CLAUDE.md`)
698+
690699
**Priority Resolution in `getSourceDir()`:**
691700

692701
1. **CLI override** (highest priority) - `-s` parameter, no rootPath prefix
@@ -988,6 +997,32 @@ ais user install
988997

989998
## Recent Changes
990999

1000+
### sourceDir Object Format for One-Source-Multi-Target (2026-03)
1001+
1002+
**Enables using the same file (e.g. `common/AGENTS.md`) for both AGENTS.md and CLAUDE.md sync.**
1003+
1004+
**Problem Solved:**
1005+
- Rules repo had `common/AGENTS.md` that should sync to project root as `AGENTS.md` (via agentsMd) and to `.claude/CLAUDE.md` (via claude-md)
1006+
- Previously required duplicate files or manual symlinks
1007+
1008+
**New sourceDir Object Format:**
1009+
```json
1010+
"claude": {
1011+
"md": { "dir": "common", "sourceFile": "AGENTS.md", "targetFile": "CLAUDE.md" }
1012+
}
1013+
```
1014+
- `dir`: Source directory (replaces default `.claude`)
1015+
- `sourceFile`: Filename to use as source (e.g. `AGENTS.md` instead of `CLAUDE.md`)
1016+
- `targetFile`: Symlink filename in target (e.g. `CLAUDE.md`)
1017+
1018+
**Implementation:**
1019+
- `src/project-config.ts` - Added `SourceDirValue` type, `getSourceFileOverride()`, `getTargetFileOverride()`, extended `buildRepoSourceFromNestedStrings` and `getSourceDir()` for object format
1020+
- `src/adapters/claude-md.ts` - Custom `resolveSource` that uses `sourceFileOverride` when set
1021+
- `src/adapters/types.ts` - Extended `resolveSource` signature with optional `options.sourceFileOverride`
1022+
- `src/dotany/types.ts` - Added `targetName` to `ResolvedSource` for target override
1023+
- `src/plugin/git-repo-source.ts` - Pass overrides to resolveSource, return `targetName` when set
1024+
- `src/dotany/manager.ts` - Use `resolved.targetName` when present
1025+
9911026
### Command UX Enhancements (2026-03)
9921027

9931028
- Added Linux-style aliases while keeping backward compatibility:

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,14 @@ Create `ai-rules-sync.json` in your rules repository:
10311031
}
10321032
```
10331033

1034+
**Use one file for both AGENTS.md and CLAUDE.md:** If you have `common/AGENTS.md` and want it to sync as both project-root `AGENTS.md` (via `agents-md`) and `.claude/CLAUDE.md` (via `claude md`), use the object format for `claude.md`:
1035+
1036+
```json
1037+
"claude": {
1038+
"md": { "dir": "common", "sourceFile": "AGENTS.md", "targetFile": "CLAUDE.md" }
1039+
}
1040+
```
1041+
10341042
### Git Commands
10351043

10361044
**Manage repository directly from CLI:**

README_ZH.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,14 @@ ais user install
10311031
}
10321032
```
10331033

1034+
**同一文件同时作为 AGENTS.md 与 CLAUDE.md:** 若你有 `common/AGENTS.md`,希望它既同步到项目根 `AGENTS.md`(通过 `agents-md`)又同步到 `.claude/CLAUDE.md`(通过 `claude md`),可为 `claude.md` 使用对象格式:
1035+
1036+
```json
1037+
"claude": {
1038+
"md": { "dir": "common", "sourceFile": "AGENTS.md", "targetFile": "CLAUDE.md" }
1039+
}
1040+
```
1041+
10341042
### Git 命令
10351043

10361044
**直接从 CLI 管理仓库:**

src/__tests__/claude-md.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import fs from 'fs-extra';
3+
import os from 'os';
4+
import path from 'path';
5+
import { claudeMdAdapter } from '../adapters/claude-md.js';
6+
import { adapterRegistry } from '../adapters/index.js';
7+
8+
describe('claude-md adapter', () => {
9+
it('should have correct basic properties', () => {
10+
expect(claudeMdAdapter.name).toBe('claude-md');
11+
expect(claudeMdAdapter.tool).toBe('claude');
12+
expect(claudeMdAdapter.subtype).toBe('md');
13+
expect(claudeMdAdapter.defaultSourceDir).toBe('.claude');
14+
expect(claudeMdAdapter.targetDir).toBe('.claude');
15+
expect(claudeMdAdapter.mode).toBe('file');
16+
expect(claudeMdAdapter.fileSuffixes).toEqual(['.md']);
17+
});
18+
19+
it('should have resolveSource and resolveTargetName hooks', () => {
20+
expect(claudeMdAdapter.resolveSource).toBeDefined();
21+
expect(claudeMdAdapter.resolveTargetName).toBeDefined();
22+
});
23+
24+
it('should be registered in adapterRegistry', () => {
25+
expect(adapterRegistry.get('claude', 'md')).toBe(claudeMdAdapter);
26+
});
27+
28+
describe('resolveSource with sourceFileOverride', () => {
29+
let repoDir: string;
30+
31+
beforeEach(async () => {
32+
repoDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ais-claude-md-'));
33+
});
34+
35+
afterEach(async () => {
36+
await fs.remove(repoDir);
37+
});
38+
39+
it('should resolve common/AGENTS.md when sourceFileOverride is set', async () => {
40+
await fs.ensureDir(path.join(repoDir, 'common'));
41+
await fs.writeFile(path.join(repoDir, 'common', 'AGENTS.md'), '# AGENTS.md content');
42+
43+
const resolved = await claudeMdAdapter.resolveSource!(
44+
repoDir,
45+
'common',
46+
'any-name',
47+
{ sourceFileOverride: 'AGENTS.md' }
48+
);
49+
50+
expect(resolved.sourceName).toBe('AGENTS.md');
51+
expect(resolved.sourcePath).toBe(path.join(repoDir, 'common', 'AGENTS.md'));
52+
expect(resolved.suffix).toBe('.md');
53+
});
54+
55+
it('should resolve common/AGENTS.md with nested path', async () => {
56+
await fs.ensureDir(path.join(repoDir, 'common'));
57+
await fs.writeFile(path.join(repoDir, 'common', 'AGENTS.md'), '# content');
58+
59+
const resolved = await claudeMdAdapter.resolveSource!(
60+
repoDir,
61+
'common',
62+
'CLAUDE',
63+
{ sourceFileOverride: 'AGENTS.md' }
64+
);
65+
66+
expect(resolved.sourcePath).toBe(path.join(repoDir, 'common', 'AGENTS.md'));
67+
});
68+
69+
it('should throw when sourceFileOverride points to non-existent file', async () => {
70+
await fs.ensureDir(path.join(repoDir, 'common'));
71+
72+
await expect(
73+
claudeMdAdapter.resolveSource!(repoDir, 'common', 'x', { sourceFileOverride: 'MISSING.md' })
74+
).rejects.toThrow(/not found/);
75+
});
76+
77+
it('should use default resolver when no sourceFileOverride', async () => {
78+
await fs.ensureDir(path.join(repoDir, '.claude'));
79+
await fs.writeFile(path.join(repoDir, '.claude', 'CLAUDE.md'), '# CLAUDE content');
80+
81+
const resolved = await claudeMdAdapter.resolveSource!(repoDir, '.claude', 'CLAUDE');
82+
83+
expect(resolved.sourceName).toBe('CLAUDE.md');
84+
expect(resolved.sourcePath).toBe(path.join(repoDir, '.claude', 'CLAUDE.md'));
85+
});
86+
});
87+
});

src/__tests__/project-config-source-dir.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
import fs from 'fs-extra';
33
import os from 'os';
44
import path from 'path';
5-
import { getRepoSourceConfig, getSourceDir } from '../project-config.js';
5+
import { getRepoSourceConfig, getSourceDir, getSourceFileOverride, getTargetFileOverride } from '../project-config.js';
66
import type { ProjectConfig, RepoSourceConfig, SourceDirConfig } from '../project-config.js';
77

88
describe('project-config source directory resolution', () => {
@@ -79,4 +79,40 @@ describe('project-config source directory resolution', () => {
7979
const repoConfig = await getRepoSourceConfig(tempDir);
8080
expect(repoConfig).toEqual({ rootPath: 'project-root' });
8181
});
82+
83+
it('should parse sourceDir object format with dir, sourceFile, targetFile', async () => {
84+
const config: ProjectConfig = {
85+
rootPath: 'rules-root',
86+
sourceDir: {
87+
claude: {
88+
md: { dir: 'common', sourceFile: 'AGENTS.md', targetFile: 'CLAUDE.md' }
89+
}
90+
}
91+
};
92+
93+
await fs.writeJson(path.join(tempDir, 'ai-rules-sync.json'), config, { spaces: 2 });
94+
95+
const repoConfig = await getRepoSourceConfig(tempDir);
96+
expect(repoConfig.claude?.md).toEqual({ dir: 'common', sourceFile: 'AGENTS.md', targetFile: 'CLAUDE.md' });
97+
98+
const sourceDir = getSourceDir(repoConfig, 'claude', 'md', '.claude');
99+
expect(sourceDir).toBe(path.join('rules-root', 'common'));
100+
101+
expect(getSourceFileOverride(repoConfig, 'claude', 'md')).toBe('AGENTS.md');
102+
expect(getTargetFileOverride(repoConfig, 'claude', 'md')).toBe('CLAUDE.md');
103+
});
104+
105+
it('should return undefined for overrides when sourceDir is string', async () => {
106+
const config: ProjectConfig = {
107+
sourceDir: {
108+
claude: { md: '.claude' }
109+
}
110+
};
111+
112+
await fs.writeJson(path.join(tempDir, 'ai-rules-sync.json'), config, { spaces: 2 });
113+
const repoConfig = await getRepoSourceConfig(tempDir);
114+
115+
expect(getSourceFileOverride(repoConfig, 'claude', 'md')).toBeUndefined();
116+
expect(getTargetFileOverride(repoConfig, 'claude', 'md')).toBeUndefined();
117+
});
82118
});

src/adapters/base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface AdapterConfig {
2424
mode: 'directory' | 'file' | 'hybrid';
2525
fileSuffixes?: string[];
2626
hybridFileSuffixes?: string[];
27-
resolveSource?: (repoDir: string, rootPath: string, name: string) => Promise<any>;
27+
resolveSource?: (repoDir: string, rootPath: string, name: string, options?: { sourceFileOverride?: string }) => Promise<any>;
2828
resolveTargetName?: (name: string, alias?: string, sourceSuffix?: string) => string;
2929
}
3030

src/adapters/claude-md.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
1-
import { SyncAdapter } from './types.js';
1+
import path from 'path';
2+
import fs from 'fs-extra';
3+
import { SyncAdapter, ResolvedSource } from './types.js';
24
import { createBaseAdapter, createSingleSuffixResolver, createSuffixAwareTargetResolver } from './base.js';
35

46
const SUFFIX = '.md';
57

8+
/**
9+
* Custom resolver that supports sourceFileOverride from rules repo sourceDir.
10+
* When sourceDir is { dir: "common", sourceFile: "AGENTS.md" }, resolves common/AGENTS.md
11+
* as the source while target remains CLAUDE.md (symlink can have different filename).
12+
*/
13+
async function resolveClaudeMdSource(
14+
repoDir: string,
15+
rootPath: string,
16+
name: string,
17+
options?: { sourceFileOverride?: string }
18+
): Promise<ResolvedSource> {
19+
if (options?.sourceFileOverride) {
20+
const sourcePath = path.join(repoDir, rootPath, options.sourceFileOverride);
21+
if (await fs.pathExists(sourcePath)) {
22+
return {
23+
sourceName: options.sourceFileOverride,
24+
sourcePath,
25+
suffix: SUFFIX,
26+
};
27+
}
28+
throw new Error(`Source file "${options.sourceFileOverride}" not found at ${path.join(rootPath, options.sourceFileOverride)}`);
29+
}
30+
return createSingleSuffixResolver(SUFFIX, 'CLAUDE.md')(repoDir, rootPath, name);
31+
}
32+
633
/**
734
* Adapter for Claude CLAUDE.md file (.claude/CLAUDE.md)
835
* Mode: file - links individual .md files from .claude/ directory
@@ -14,6 +41,9 @@ const SUFFIX = '.md';
1441
* Project mode usage:
1542
* ais claude md add CLAUDE
1643
* → creates symlink at ./.claude/CLAUDE.md
44+
*
45+
* Rules repo sourceDir override (use common/AGENTS.md as CLAUDE.md source):
46+
* sourceDir: { claude: { md: { dir: "common", sourceFile: "AGENTS.md" } } }
1747
*/
1848
export const claudeMdAdapter: SyncAdapter = createBaseAdapter({
1949
name: 'claude-md',
@@ -25,6 +55,6 @@ export const claudeMdAdapter: SyncAdapter = createBaseAdapter({
2555
mode: 'file',
2656
fileSuffixes: [SUFFIX],
2757

28-
resolveSource: createSingleSuffixResolver(SUFFIX, 'CLAUDE.md'),
58+
resolveSource: resolveClaudeMdSource,
2959
resolveTargetName: createSuffixAwareTargetResolver([SUFFIX])
3060
});

src/adapters/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ export interface SyncAdapter {
4141
* Optional hook to resolve the actual source path.
4242
* Default behavior: join(repoDir, rootPath, name)
4343
* For file mode with suffixes, this handles suffix resolution.
44+
* @param options.sourceFileOverride - When set (from rules repo sourceDir), use this file instead of name
4445
*/
4546
resolveSource?(
4647
repoDir: string,
4748
rootPath: string,
48-
name: string
49+
name: string,
50+
options?: { sourceFileOverride?: string }
4951
): Promise<ResolvedSource>;
5052

5153
/**

src/dotany/manager.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ export class DotfileManager {
3232
// Resolve source
3333
const resolved = await this.opts.source.resolve(name, { repoUrl, targetDir });
3434

35-
// Resolve target name (suffix-aware if resolver provided)
36-
const targetName = this.opts.resolveTargetName
37-
? this.opts.resolveTargetName(name, alias, resolved.suffix)
38-
: (alias || resolved.name);
35+
// Resolve target name: use override from source, or suffix-aware resolver, or alias/name
36+
const targetName = resolved.targetName
37+
?? (this.opts.resolveTargetName
38+
? this.opts.resolveTargetName(name, alias, resolved.suffix)
39+
: (alias || resolved.name));
3940

4041
// Determine target directory
4142
const targetDirPath = targetDir ? path.normalize(targetDir) : this.opts.targetDir;
@@ -128,9 +129,10 @@ export class DotfileManager {
128129
if (this.opts.source.resolveFromManifest) {
129130
// Use manifest-aware resolution (supports multi-repo)
130131
const resolved = await this.opts.source.resolveFromManifest(entry);
131-
const targetName = this.opts.resolveTargetName
132-
? this.opts.resolveTargetName(entry.sourceName, alias !== entry.sourceName ? alias : undefined, resolved.suffix)
133-
: (alias !== entry.sourceName ? alias : resolved.name);
132+
const targetName = resolved.targetName
133+
?? (this.opts.resolveTargetName
134+
? this.opts.resolveTargetName(entry.sourceName, alias !== entry.sourceName ? alias : undefined, resolved.suffix)
135+
: (alias !== entry.sourceName ? alias : resolved.name));
134136
const targetDirPath = entry.meta?.targetDir
135137
? path.normalize(entry.meta.targetDir as string)
136138
: this.opts.targetDir;

src/dotany/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface ResolvedSource {
2525
path: string;
2626
/** Detected suffix, if any */
2727
suffix?: string;
28+
/** Override target filename (e.g. when sourceFile maps AGENTS.md -> CLAUDE.md) */
29+
targetName?: string;
2830
}
2931

3032
/** Pluggable manifest persistence interface */

0 commit comments

Comments
 (0)