Skip to content

Commit 0c7358a

Browse files
committed
feat(cli): add check update init lifecycle commands
1 parent adf9694 commit 0c7358a

9 files changed

Lines changed: 924 additions & 4 deletions

File tree

KNOWLEDGE_BASE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ src/
7272
│ ├── helpers.ts # Helper functions (getTargetRepo, parseConfigEntry, etc.)
7373
│ ├── install.ts # Generic install function
7474
│ ├── add-all.ts # Discover and install all entries from repository
75+
│ ├── lifecycle.ts # check/update/init lifecycle commands
7576
│ └── index.ts # Module exports
7677
├── completion/ # Shell completion
7778
│ └── scripts.ts # Shell completion scripts (bash, zsh, fish)
@@ -189,6 +190,11 @@ async function installEntriesForAdapter(adapter, projectPath): Promise<void>
189190
async function installEntriesForTool(adapters[], projectPath): Promise<void>
190191
```
191192

193+
**Repository Lifecycle Commands (`src/commands/lifecycle.ts`):**
194+
- `checkRepositories(options)`: collects repo URLs from project/user config and compares local HEAD vs upstream (`ahead/behind`) using `git fetch` + `git rev-list`.
195+
- `updateRepositories(options)`: updates repos (`git pull` via `cloneOrUpdateRepo`) and reapplies links from config.
196+
- `initRulesRepository(options)`: scaffolds `ai-rules-sync.json` with adapter-driven `sourceDir` defaults and creates default source directories.
197+
192198
**Helper Functions (`src/commands/helpers.ts`):**
193199
- `getTargetRepo(options)`: Resolve target repository from options or config
194200
- `inferDefaultMode(projectPath)`: Auto-detect cursor/copilot mode from config

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Stop copying `.mdc` files around. Manage your rules in Git repositories and sync
2323
- [Core Concepts](#core-concepts)
2424
- [Recommended Command Style](#recommended-command-style)
2525
- [Basic Usage](#basic-usage)
26+
- [Repository Lifecycle](#repository-lifecycle)
2627
- [Tool-Specific Guides](#tool-specific-guides)
2728
- [Advanced Features](#advanced-features)
2829
- [User Mode](#user-mode-personal-ai-config-files)
@@ -286,17 +287,20 @@ ais cursor rules remove react
286287
# Query commands
287288
ais status
288289
ais search react
290+
ais check
289291

290292
# Script/CI JSON output
291293
ais ls --json
292294
ais status --json
293295
ais search react --json
296+
ais check --json
294297
ais config repo ls --json
295298
ais config repo show company-rules --json
296299

297300
# Safe preview before destructive operations
298301
ais cursor rules rm react --dry-run
299302
ais cursor rules import my-rule --dry-run
303+
ais update --dry-run
300304
```
301305

302306
---
@@ -317,11 +321,13 @@ mkdir ~/my-rules-repo
317321
cd ~/my-rules-repo
318322
git init
319323

324+
# Scaffold default ai-rules-sync.json + source directories
325+
ais init
326+
320327
# Set as current repository
321328
ais use ~/my-rules-repo
322329

323-
# Create rules structure
324-
mkdir -p .cursor/rules
330+
# Add your first rule
325331
echo "# React Rules" > .cursor/rules/react.mdc
326332
git add .
327333
git commit -m "Initial commit"
@@ -424,6 +430,28 @@ ais copilot install # All copilot entries (instructions + skills)
424430
ais install # All tools
425431
```
426432

433+
## Repository Lifecycle
434+
435+
```bash
436+
# Check whether configured repositories are behind upstream
437+
ais check
438+
439+
# Check user config repositories
440+
ais check --user
441+
442+
# Preview updates without pulling repositories
443+
ais update --dry-run
444+
445+
# Pull updates and reinstall entries from config
446+
ais update
447+
448+
# Initialize a rules repository template in current directory
449+
ais init
450+
451+
# Initialize template in a subdirectory
452+
ais init my-rules-repo
453+
```
454+
427455
---
428456

429457
## Tool-Specific Guides

README_ZH.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
- [核心概念](#核心概念)
2424
- [推荐命令风格](#推荐命令风格)
2525
- [基础使用](#基础使用)
26+
- [仓库生命周期](#仓库生命周期)
2627
- [各工具使用指南](#各工具使用指南)
2728
- [高级功能](#高级功能)
2829
- [User 模式](#user-模式个人-ai-配置文件)
@@ -286,17 +287,20 @@ ais cursor rules remove react
286287
# 查询命令
287288
ais status
288289
ais search react
290+
ais check
289291

290292
# 脚本/CI 的 JSON 输出
291293
ais ls --json
292294
ais status --json
293295
ais search react --json
296+
ais check --json
294297
ais config repo ls --json
295298
ais config repo show company-rules --json
296299

297300
# 破坏性操作前先预览
298301
ais cursor rules rm react --dry-run
299302
ais cursor rules import my-rule --dry-run
303+
ais update --dry-run
300304
```
301305

302306
---
@@ -317,11 +321,13 @@ mkdir ~/my-rules-repo
317321
cd ~/my-rules-repo
318322
git init
319323

324+
# 生成默认 ai-rules-sync.json 和源目录结构
325+
ais init
326+
320327
# 设置为当前仓库
321328
ais use ~/my-rules-repo
322329

323-
# 创建规则结构
324-
mkdir -p .cursor/rules
330+
# 添加第一条规则
325331
echo "# React Rules" > .cursor/rules/react.mdc
326332
git add .
327333
git commit -m "Initial commit"
@@ -424,6 +430,28 @@ ais copilot install # 所有 copilot 条目(指令 + 技能 + 提示词 + 代
424430
ais install # 所有工具
425431
```
426432

433+
## 仓库生命周期
434+
435+
```bash
436+
# 检查配置中依赖仓库是否落后于远端
437+
ais check
438+
439+
# 检查 user 配置中的仓库
440+
ais check --user
441+
442+
# 只预览更新,不执行 pull
443+
ais update --dry-run
444+
445+
# 拉取仓库更新并根据配置重装链接
446+
ais update
447+
448+
# 在当前目录初始化规则仓库模板
449+
ais init
450+
451+
# 在子目录初始化模板
452+
ais init my-rules-repo
453+
```
454+
427455
---
428456

429457
## 各工具使用指南
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import fs from 'fs-extra';
3+
import { execa } from 'execa';
4+
import { checkRepositories, updateRepositories } from '../commands/lifecycle.js';
5+
import { getConfig, setConfig, getUserProjectConfig } from '../config.js';
6+
import { getCombinedProjectConfig } from '../project-config.js';
7+
import { cloneOrUpdateRepo } from '../git.js';
8+
import { installAllUserEntries, installEntriesForAdapter } from '../commands/install.js';
9+
10+
vi.mock('execa', () => ({
11+
execa: vi.fn()
12+
}));
13+
14+
vi.mock('../config.js', () => ({
15+
getConfig: vi.fn(),
16+
setConfig: vi.fn(),
17+
getReposBaseDir: vi.fn(() => '/tmp/repos'),
18+
getUserProjectConfig: vi.fn()
19+
}));
20+
21+
vi.mock('../project-config.js', () => ({
22+
getCombinedProjectConfig: vi.fn()
23+
}));
24+
25+
vi.mock('../git.js', () => ({
26+
cloneOrUpdateRepo: vi.fn()
27+
}));
28+
29+
vi.mock('../commands/install.js', () => ({
30+
installAllUserEntries: vi.fn(),
31+
installEntriesForAdapter: vi.fn()
32+
}));
33+
34+
vi.mock('../adapters/index.js', () => ({
35+
adapterRegistry: {
36+
all: vi.fn(() => [{
37+
name: 'cursor-rules',
38+
tool: 'cursor',
39+
subtype: 'rules',
40+
configPath: ['cursor', 'rules'],
41+
defaultSourceDir: '.cursor/rules'
42+
}])
43+
}
44+
}));
45+
46+
describe('lifecycle check/update', () => {
47+
beforeEach(() => {
48+
vi.resetAllMocks();
49+
});
50+
51+
it('should detect update-available repositories', async () => {
52+
vi.mocked(getCombinedProjectConfig).mockResolvedValue({
53+
cursor: {
54+
rules: {
55+
react: 'https://example.com/rules.git'
56+
}
57+
}
58+
} as any);
59+
vi.mocked(getConfig).mockResolvedValue({
60+
currentRepo: 'rules',
61+
repos: {
62+
rules: {
63+
name: 'rules',
64+
url: 'https://example.com/rules.git',
65+
path: '/tmp/rules'
66+
}
67+
}
68+
} as any);
69+
70+
vi.spyOn(fs, 'pathExists').mockImplementation(async (target: fs.PathLike) => {
71+
const value = String(target);
72+
return value === '/tmp/rules' || value === '/tmp/rules/.git';
73+
});
74+
75+
const execaMock = vi.mocked(execa as any);
76+
execaMock.mockImplementation(async (_cmd: string, args: string[]) => {
77+
const command = args.join(' ');
78+
if (command === 'fetch --quiet') return { stdout: '' };
79+
if (command === 'rev-parse --short HEAD') return { stdout: 'abc1234' };
80+
if (command === 'rev-parse --abbrev-ref --symbolic-full-name @{u}') return { stdout: 'origin/main' };
81+
if (command === 'rev-list --left-right --count HEAD...@{u}') return { stdout: '0\t2' };
82+
throw new Error(`Unexpected git command: ${command}`);
83+
});
84+
85+
const result = await checkRepositories({
86+
projectPath: '/tmp/project'
87+
});
88+
89+
expect(result.total).toBe(1);
90+
expect(result.updateAvailable).toBe(1);
91+
expect(result.entries[0]).toMatchObject({
92+
repoUrl: 'https://example.com/rules.git',
93+
status: 'update-available',
94+
ahead: 0,
95+
behind: 2
96+
});
97+
});
98+
99+
it('should preview updates in dry-run mode without pulling', async () => {
100+
vi.mocked(getCombinedProjectConfig).mockResolvedValue({
101+
cursor: {
102+
rules: {
103+
react: 'https://example.com/rules.git'
104+
}
105+
}
106+
} as any);
107+
vi.mocked(getUserProjectConfig).mockResolvedValue({} as any);
108+
vi.mocked(getConfig).mockResolvedValue({
109+
currentRepo: 'rules',
110+
repos: {
111+
rules: {
112+
name: 'rules',
113+
url: 'https://example.com/rules.git',
114+
path: '/tmp/rules'
115+
}
116+
}
117+
} as any);
118+
119+
vi.spyOn(fs, 'pathExists').mockImplementation(async (target: fs.PathLike) => {
120+
const value = String(target);
121+
return value === '/tmp/rules' || value === '/tmp/rules/.git';
122+
});
123+
124+
const execaMock = vi.mocked(execa as any);
125+
execaMock.mockImplementation(async (_cmd: string, args: string[]) => {
126+
const command = args.join(' ');
127+
if (command === 'fetch --quiet') return { stdout: '' };
128+
if (command === 'rev-parse --short HEAD') return { stdout: 'abc1234' };
129+
if (command === 'rev-parse --abbrev-ref --symbolic-full-name @{u}') return { stdout: 'origin/main' };
130+
if (command === 'rev-list --left-right --count HEAD...@{u}') return { stdout: '0\t1' };
131+
throw new Error(`Unexpected git command: ${command}`);
132+
});
133+
134+
const result = await updateRepositories({
135+
projectPath: '/tmp/project',
136+
dryRun: true
137+
});
138+
139+
expect(result.dryRun).toBe(true);
140+
expect(result.total).toBe(1);
141+
expect(result.entries[0]).toMatchObject({
142+
repoUrl: 'https://example.com/rules.git',
143+
action: 'would-update'
144+
});
145+
expect(cloneOrUpdateRepo).not.toHaveBeenCalled();
146+
expect(setConfig).not.toHaveBeenCalled();
147+
expect(installAllUserEntries).not.toHaveBeenCalled();
148+
expect(installEntriesForAdapter).not.toHaveBeenCalled();
149+
});
150+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import os from 'os';
2+
import path from 'path';
3+
import fs from 'fs-extra';
4+
import { describe, expect, it } from 'vitest';
5+
import { initRulesRepository } from '../commands/lifecycle.js';
6+
7+
describe('initRulesRepository', () => {
8+
it('should create a repository template with sourceDir config', async () => {
9+
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'ais-init-'));
10+
const result = await initRulesRepository({
11+
cwd,
12+
name: 'rules-template'
13+
});
14+
15+
const configExists = await fs.pathExists(result.configPath);
16+
expect(configExists).toBe(true);
17+
18+
const config = await fs.readJson(result.configPath);
19+
expect(config.sourceDir?.cursor?.rules).toBe('.cursor/rules');
20+
expect(config.sourceDir?.copilot?.instructions).toBe('.github/instructions');
21+
22+
const cursorDir = path.join(result.projectPath, '.cursor', 'rules');
23+
expect(await fs.pathExists(cursorDir)).toBe(true);
24+
expect(result.createdDirectories.length).toBeGreaterThan(0);
25+
});
26+
27+
it('should throw if ai-rules-sync.json exists and force is not enabled', async () => {
28+
const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ais-init-force-'));
29+
const configPath = path.join(projectPath, 'ai-rules-sync.json');
30+
await fs.writeJson(configPath, { sourceDir: {} }, { spaces: 2 });
31+
32+
await expect(initRulesRepository({ cwd: projectPath })).rejects.toThrow('already exists');
33+
});
34+
});

src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
export * from './helpers.js';
66
export * from './handlers.js';
77
export * from './install.js';
8+
export * from './lifecycle.js';

0 commit comments

Comments
 (0)