Skip to content

Commit 4b00a04

Browse files
lbb00cursoragent
andauthored
Feat/ais use local path symlink (#33)
* docs: fix ais init in Quick Start and enhance init command documentation - Add ais init to Scenario 2 Option A (create new repo flow) in both READMEs - Fix incomplete flow: mkdir/cd/git init/ais init/ais use - Enhance Repository Lifecycle section with init options (--force, --no-dirs, --json) - Clarify that ais init creates ai-rules-sync.json and source directories Co-authored-by: lbb <mr@lbb00.com> * feat: support ais use with local path and symlink for git repos - ais use ~/path: symlink git repos to repos/, use remote URL for portable config - Check repos/ conflict and error with clear message - -t option supports local path with same symlink logic - Strip credentials from URLs for safe config sharing - Non-git or no-remote repos: use path directly (legacy behavior) Co-authored-by: lbb <mr@lbb00.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent aa53caa commit 4b00a04

File tree

8 files changed

+382
-31
lines changed

8 files changed

+382
-31
lines changed

README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,14 @@ ais cursor add testing
159159
```bash
160160
# 1. Create a rules repository (or use existing one)
161161
# Option A: Create new repository
162-
git init ~/my-rules-repo
163-
ais use ~/my-rules-repo
162+
mkdir ~/my-rules-repo && cd ~/my-rules-repo
163+
git init
164+
ais init
165+
ais use .
164166

165-
# Option B: Use existing repository
167+
# Option B: Use existing repository (URL or local path)
166168
ais use https://github.com/your-org/rules-repo.git
169+
ais use ~/my-rules-repo
167170

168171
# 2. Import your existing rule
169172
cd your-project
@@ -211,7 +214,7 @@ my-rules-repo/
211214

212215
**Repository Locations:**
213216
- **Global**: `~/.config/ai-rules-sync/repos/` (managed by AIS)
214-
- **Local**: Any local path (for development)
217+
- **Local**: `ais use ~/path` with a git repo creates a symlink in `repos/`; rules reference the remote URL for portable config
215218

216219
**Managing Repositories:**
217220
```bash
@@ -311,7 +314,12 @@ ais update --dry-run
311314

312315
**Option 1: Use an existing repository**
313316
```bash
317+
# Remote URL (cloned to ~/.config/ai-rules-sync/repos/)
314318
ais use https://github.com/your-org/rules-repo.git
319+
320+
# Local path (git repo with remote: symlinked to repos/; rules reference remote URL)
321+
ais use ~/my-rules-repo
322+
ais use ./path/to/repo
315323
```
316324

317325
**Option 2: Create a new local repository**
@@ -321,7 +329,7 @@ mkdir ~/my-rules-repo
321329
cd ~/my-rules-repo
322330
git init
323331

324-
# Scaffold default ai-rules-sync.json + source directories
332+
# Scaffold ai-rules-sync.json and create default source directories (.cursor/rules/, etc.)
325333
ais init
326334

327335
# Set as current repository
@@ -445,11 +453,14 @@ ais update --dry-run
445453
# Pull updates and reinstall entries from config
446454
ais update
447455

448-
# Initialize a rules repository template in current directory
456+
# Initialize a rules repository template (creates ai-rules-sync.json + source dirs)
449457
ais init
450458

451-
# Initialize template in a subdirectory
459+
# Initialize in a subdirectory
452460
ais init my-rules-repo
461+
462+
# Options: --force (overwrite existing), --no-dirs (config only), --json (machine output)
463+
ais init --help
453464
```
454465

455466
---
@@ -760,7 +771,7 @@ ais use personal-rules
760771

761772
All commands support:
762773

763-
- `-t, --target <repo>`: Specify repository (name or URL)
774+
- `-t, --target <repo>`: Specify repository (name, URL, or local path)
764775
- `-l, --local`: Save to `ai-rules-sync.local.json` (private)
765776

766777
Examples:

README_ZH.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,14 @@ ais cursor add testing
159159
```bash
160160
# 1. 创建规则仓库(或使用现有仓库)
161161
# 选项 A:创建新仓库
162-
git init ~/my-rules-repo
163-
ais use ~/my-rules-repo
162+
mkdir ~/my-rules-repo && cd ~/my-rules-repo
163+
git init
164+
ais init
165+
ais use .
164166

165-
# 选项 B:使用现有仓库
167+
# 选项 B:使用现有仓库(URL 或本地路径)
166168
ais use https://github.com/your-org/rules-repo.git
169+
ais use ~/my-rules-repo
167170

168171
# 2. 导入你的现有规则
169172
cd your-project
@@ -311,7 +314,12 @@ ais update --dry-run
311314

312315
**选项 1:使用现有仓库**
313316
```bash
317+
# 远程 URL(会克隆到 ~/.config/ai-rules-sync/repos/)
314318
ais use https://github.com/your-org/rules-repo.git
319+
320+
# 本地路径(有 remote 的 git 仓库:会 symlink 到 repos/;规则引用 remote URL)
321+
ais use ~/my-rules-repo
322+
ais use ./path/to/repo
315323
```
316324

317325
**选项 2:创建新的本地仓库**
@@ -321,7 +329,7 @@ mkdir ~/my-rules-repo
321329
cd ~/my-rules-repo
322330
git init
323331

324-
# 生成默认 ai-rules-sync.json 和源目录结构
332+
# 生成 ai-rules-sync.json 并创建默认源目录(.cursor/rules/ 等)
325333
ais init
326334

327335
# 设置为当前仓库
@@ -445,11 +453,14 @@ ais update --dry-run
445453
# 拉取仓库更新并根据配置重装链接
446454
ais update
447455

448-
# 在当前目录初始化规则仓库模板
456+
# 初始化规则仓库模板(创建 ai-rules-sync.json 和源目录)
449457
ais init
450458

451-
# 在子目录初始化模板
459+
# 在子目录初始化
452460
ais init my-rules-repo
461+
462+
# 选项:--force(覆盖已有)、--no-dirs(仅配置)、--json(机器输出)
463+
ais init --help
453464
```
454465

455466
---
@@ -760,7 +771,7 @@ ais use personal-rules
760771

761772
所有命令都支持:
762773

763-
- `-t, --target <repo>`:指定仓库(名称或 URL)
774+
- `-t, --target <repo>`:指定仓库(名称、URL 或本地路径
764775
- `-l, --local`:保存到 `ai-rules-sync.local.json`(私有)
765776

766777
示例:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import os from 'os';
2+
import path from 'path';
3+
import fs from 'fs-extra';
4+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
5+
import { getConfig, setConfig } from '../config.js';
6+
import { isLocalPath, resolveLocalPath } from '../utils.js';
7+
import { isLocalRepo, stripUrlCredentials } from '../git.js';
8+
9+
describe('isLocalPath', () => {
10+
it('should return false for URLs', () => {
11+
expect(isLocalPath('https://github.com/org/repo.git')).toBe(false);
12+
expect(isLocalPath('git@github.com:org/repo.git')).toBe(false);
13+
expect(isLocalPath('ssh://git@host/repo')).toBe(false);
14+
});
15+
16+
it('should return false for bare .git suffix (likely URL)', () => {
17+
expect(isLocalPath('repo.git')).toBe(false);
18+
});
19+
20+
it('should return true for absolute paths', () => {
21+
expect(isLocalPath('/home/user/repo')).toBe(true);
22+
expect(isLocalPath('/tmp/repo')).toBe(true);
23+
});
24+
25+
it('should return true for home-relative paths', () => {
26+
expect(isLocalPath('~/repo')).toBe(true);
27+
expect(isLocalPath('~/my-rules-repo')).toBe(true);
28+
});
29+
30+
it('should return true for relative paths', () => {
31+
expect(isLocalPath('./repo')).toBe(true);
32+
expect(isLocalPath('../repo')).toBe(true);
33+
});
34+
});
35+
36+
describe('resolveLocalPath', () => {
37+
it('should resolve and verify existing directory', async () => {
38+
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'ais-resolve-'));
39+
const subDir = path.join(cwd, 'subdir');
40+
await fs.ensureDir(subDir);
41+
42+
const resolved = await resolveLocalPath('./subdir', cwd);
43+
expect(resolved).toBe(subDir);
44+
45+
const absResolved = await resolveLocalPath(cwd);
46+
expect(absResolved).toBe(cwd);
47+
});
48+
49+
it('should return null for non-existent path', async () => {
50+
const resolved = await resolveLocalPath('/nonexistent/path/12345');
51+
expect(resolved).toBeNull();
52+
});
53+
54+
it('should expand ~ to homedir', async () => {
55+
const inHome = path.join(os.homedir(), 'ais-test-dir');
56+
await fs.ensureDir(inHome);
57+
try {
58+
const resolved = await resolveLocalPath('~/ais-test-dir');
59+
expect(resolved).toBe(inHome);
60+
} finally {
61+
await fs.remove(inHome).catch(() => {});
62+
}
63+
});
64+
});
65+
66+
describe('isLocalRepo', () => {
67+
it('should return true when url is a path', () => {
68+
expect(isLocalRepo({ name: 'x', url: '/home/user/repo', path: '/home/user/repo' })).toBe(true);
69+
expect(isLocalRepo({ name: 'x', url: '~/repo', path: '/home/user/repo' })).toBe(true);
70+
});
71+
72+
it('should return false when url is a remote URL', () => {
73+
expect(isLocalRepo({ name: 'x', url: 'https://github.com/org/repo', path: '/tmp/repo' })).toBe(false);
74+
expect(isLocalRepo({ name: 'x', url: 'git@github.com:org/repo.git', path: '/tmp/repo' })).toBe(false);
75+
});
76+
});
77+
78+
describe('stripUrlCredentials', () => {
79+
it('should strip credentials from https URL', () => {
80+
expect(stripUrlCredentials('https://token@github.com/org/repo.git')).toBe('https://github.com/org/repo.git');
81+
expect(stripUrlCredentials('https://user:pass@host/path')).toBe('https://host/path');
82+
});
83+
84+
it('should leave git@ URLs unchanged', () => {
85+
expect(stripUrlCredentials('git@github.com:org/repo.git')).toBe('git@github.com:org/repo.git');
86+
});
87+
});

src/commands/helpers.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
import path from 'path';
66
import chalk from 'chalk';
7+
import fs from 'fs-extra';
78
import { getConfig, setConfig, getReposBaseDir, getCurrentRepo, RepoConfig } from '../config.js';
8-
import { cloneOrUpdateRepo } from '../git.js';
9+
import { cloneOrUpdateRepo, getRemoteUrl } from '../git.js';
910
import { getCombinedProjectConfig } from '../project-config.js';
1011
import { stripCopilotSuffix, adapterRegistry } from '../adapters/index.js';
12+
import { isLocalPath, resolveLocalPath } from '../utils.js';
1113

1214
/**
1315
* Get the target repository based on CLI options
@@ -60,6 +62,76 @@ export async function getTargetRepo(options: { target?: string }): Promise<RepoC
6062
return newRepo;
6163
}
6264

65+
// 3. Try as local path
66+
if (isLocalPath(target)) {
67+
const resolvedPath = await resolveLocalPath(target, process.cwd());
68+
if (!resolvedPath) {
69+
throw new Error(`Path "${target}" does not exist or is not a directory.`);
70+
}
71+
const remoteUrl = await getRemoteUrl(resolvedPath);
72+
const isGit = await fs.pathExists(path.join(resolvedPath, '.git'));
73+
74+
if (isGit && remoteUrl) {
75+
// Git repo with remote: symlink to repos/ and use remote URL
76+
let name = path.basename(resolvedPath) || 'local-repo';
77+
const symlinkPath = path.join(getReposBaseDir(), name);
78+
79+
if (config.repos) {
80+
for (const repo of Object.values(config.repos)) {
81+
if (repo.url === remoteUrl) return repo;
82+
if (path.resolve(repo.path) === path.resolve(resolvedPath)) return repo;
83+
if (path.resolve(await fs.realpath(repo.path).catch(() => '')) === path.resolve(resolvedPath)) return repo;
84+
}
85+
}
86+
87+
if (await fs.pathExists(symlinkPath)) {
88+
const stat = await fs.lstat(symlinkPath);
89+
if (stat.isSymbolicLink()) {
90+
const symTarget = await fs.realpath(symlinkPath);
91+
if (path.resolve(symTarget) === path.resolve(resolvedPath)) {
92+
const repo = config.repos?.[name] ?? { name, url: remoteUrl, path: symlinkPath };
93+
await setConfig({ repos: { ...(config.repos || {}), [name]: repo } });
94+
return repo;
95+
}
96+
}
97+
throw new Error(
98+
`Repository "${name}" already exists at ${symlinkPath}. ` +
99+
`Use "ais use ${name}" or remove it first.`
100+
);
101+
}
102+
103+
for (const repo of Object.values(config.repos || {})) {
104+
if (repo.url === remoteUrl) {
105+
throw new Error(`Repository with remote "${remoteUrl}" is already configured. Use "ais use <name>".`);
106+
}
107+
}
108+
109+
await fs.ensureDir(getReposBaseDir());
110+
await fs.symlink(resolvedPath, symlinkPath);
111+
const newRepo: RepoConfig = { name, url: remoteUrl, path: symlinkPath };
112+
await setConfig({ repos: { ...(config.repos || {}), [name]: newRepo } });
113+
await cloneOrUpdateRepo(newRepo);
114+
return newRepo;
115+
}
116+
117+
// Non-git or no remote: use path directly
118+
if (config.repos) {
119+
for (const repo of Object.values(config.repos)) {
120+
if (path.resolve(repo.path) === path.resolve(resolvedPath)) return repo;
121+
}
122+
}
123+
console.log(chalk.blue(`Using local repository at ${resolvedPath}`));
124+
let name = path.basename(resolvedPath) || 'local-repo';
125+
if (config.repos && config.repos[name] && config.repos[name].path !== resolvedPath) {
126+
name = `${name}-${Date.now()}`;
127+
}
128+
const url = remoteUrl || resolvedPath;
129+
const newRepo: RepoConfig = { name, url, path: resolvedPath };
130+
await setConfig({ repos: { ...(config.repos || {}), [name]: newRepo } });
131+
await cloneOrUpdateRepo(newRepo);
132+
return newRepo;
133+
}
134+
63135
throw new Error(`Repository "${target}" not found in configuration.`);
64136
}
65137

src/commands/install.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { RuleEntry } from '../project-config.js';
1111
import { getConfig, setConfig, getReposBaseDir, getUserProjectConfig, getUserConfigPath, RepoConfig } from '../config.js';
1212
import { cloneOrUpdateRepo } from '../git.js';
1313
import { parseConfigEntry } from './helpers.js';
14+
import { isLocalPath, resolveLocalPath } from '../utils.js';
1415
import type { RepoResolverFn } from '../dotany/types.js';
1516

1617
/**
@@ -21,7 +22,7 @@ async function findOrCreateRepo(
2122
repoUrl: string,
2223
entryName: string
2324
): Promise<RepoConfig> {
24-
// Check if repo already exists
25+
// Check if repo already exists (by url or by path for local repos)
2526
for (const k in repos) {
2627
if (repos[k].url === repoUrl) {
2728
const repo = repos[k];
@@ -30,9 +31,29 @@ async function findOrCreateRepo(
3031
}
3132
return repo;
3233
}
34+
if (path.resolve(repos[k].path) === path.resolve(repoUrl)) {
35+
return repos[k];
36+
}
37+
}
38+
39+
// Local path: use directly if it exists
40+
if (isLocalPath(repoUrl)) {
41+
const resolvedPath = await resolveLocalPath(repoUrl, process.cwd());
42+
if (resolvedPath) {
43+
let name = path.basename(resolvedPath) || `repo-${Date.now()}`;
44+
if (repos[name]) name = `${name}-${Date.now()}`;
45+
const repoConfig: RepoConfig = { name, url: resolvedPath, path: resolvedPath };
46+
await setConfig({ repos: { ...repos, [name]: repoConfig } });
47+
repos[name] = repoConfig;
48+
return repoConfig;
49+
}
50+
throw new Error(
51+
`Local repository path "${repoUrl}" does not exist. ` +
52+
`Run "ais use <path>" first if this is a shared project.`
53+
);
3354
}
3455

35-
// Create new repo config
56+
// Create new repo config (remote URL)
3657
console.log(chalk.yellow(`Repository for ${entryName} not found locally. Configuring...`));
3758

3859
let name = path.basename(repoUrl, '.git');

0 commit comments

Comments
 (0)