Skip to content

Commit a5c930b

Browse files
authored
Merge pull request #749 from dyoshikawa/codex/implement-cline-command.ts-with-workflows
Add cline command workflow support
2 parents 71bef0f + e75c907 commit a5c930b

File tree

10 files changed

+384
-10
lines changed

10 files changed

+384
-10
lines changed

.devcontainer/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ RUN apt update && apt install -y less \
2222
jq \
2323
vim \
2424
bubblewrap \
25-
socat
25+
socat \
26+
ripgrep
2627

2728
# Ensure default node user has access to /usr/local/share
2829
RUN mkdir -p /usr/local/share/npm-global && \

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ modular-mcp.json
219219
**/.claude/settings.local.json
220220
**/.mcp.json
221221
**/.clinerules/
222+
**/.clinerules/workflows/
222223
**/.clineignore
223224
**/.cline/mcp.json
224225
**/.codexignore

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
135135
| GitHub Copilot || |||||
136136
| Cursor |||| ✅ 🌏 | 🎮 ||
137137
| OpenCode || || ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
138-
| Cline |||| | | |
138+
| Cline |||| ✅ 🌏 | | |
139139
| Roo Code ||||| 🎮 | ✅ 🌏 |
140140
| Qwen Code ||| | | | |
141141
| Kiro IDE ||| | | | |

opencode.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://opencode.ai/config.json",
3-
"model": "openrouter/openai/gpt-5.1-codex",
3+
"lsp": false,
44
"provider": {
55
"openrouter": {
66
"models": {
@@ -14,7 +14,7 @@
1414
"agent": {
1515
"build": {
1616
"mode": "primary",
17-
"model": "openrouter/openai/gpt-5.1-codex",
17+
"model": "openrouter/z-ai/glm-4.7",
1818
"tools": {
1919
"write": true,
2020
"edit": true,
@@ -23,7 +23,7 @@
2323
},
2424
"plan": {
2525
"mode": "primary",
26-
"model": "openrouter/openai/gpt-5.1-codex",
26+
"model": "openrouter/z-ai/glm-4.7",
2727
"tools": {
2828
"write": false,
2929
"edit": false,

src/cli/commands/gitignore.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe("gitignoreCommand", () => {
5151
expect(content).toContain("**/.amazonq/");
5252
expect(content).toContain("**/.cursor/");
5353
expect(content).toContain("**/.clinerules/");
54+
expect(content).toContain("**/.clinerules/workflows/");
5455
expect(content).toContain("**/CLAUDE.md");
5556
expect(content).toContain("**/.opencode/agent/");
5657
expect(content).toContain("**/.gemini/memories/");
@@ -202,6 +203,7 @@ dist/`;
202203
**/.claude/settings.local.json
203204
**/.mcp.json
204205
**/.clinerules/
206+
**/.clinerules/workflows/
205207
**/.clineignore
206208
**/.cline/mcp.json
207209
**/.codexignore

src/cli/commands/gitignore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const RULESYNC_IGNORE_ENTRIES = [
2727
"**/.mcp.json",
2828
// Cline
2929
"**/.clinerules/",
30+
"**/.clinerules/workflows/",
3031
"**/.clineignore",
3132
"**/.cline/mcp.json",
3233
// Codex
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { join } from "node:path";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
4+
import { setupTestDirectory } from "../../test-utils/test-directories.js";
5+
import { writeFileContent } from "../../utils/file.js";
6+
import { ClineCommand } from "./cline-command.js";
7+
import { RulesyncCommand } from "./rulesync-command.js";
8+
9+
describe("ClineCommand", () => {
10+
let testDir: string;
11+
let cleanup: () => Promise<void>;
12+
let originalHome: string | undefined;
13+
let originalUserProfile: string | undefined;
14+
15+
const validContent = "# Sample workflow\n\nDo something helpful.";
16+
17+
const markdownWithFrontmatter = `---
18+
title: Test workflow
19+
---
20+
21+
# Workflow
22+
Step 1`;
23+
24+
beforeEach(async () => {
25+
const testSetup = await setupTestDirectory();
26+
testDir = testSetup.testDir;
27+
cleanup = testSetup.cleanup;
28+
originalHome = process.env.HOME;
29+
originalUserProfile = process.env.USERPROFILE;
30+
process.env.HOME = testDir;
31+
process.env.USERPROFILE = testDir;
32+
vi.spyOn(process, "cwd").mockReturnValue(testDir);
33+
});
34+
35+
afterEach(async () => {
36+
process.env.HOME = originalHome;
37+
process.env.USERPROFILE = originalUserProfile;
38+
await cleanup();
39+
vi.restoreAllMocks();
40+
});
41+
42+
describe("getSettablePaths", () => {
43+
it("should return project paths for cline workflows", () => {
44+
const paths = ClineCommand.getSettablePaths();
45+
46+
expect(paths).toEqual({ relativeDirPath: join(".clinerules", "workflows") });
47+
});
48+
49+
it("should return global workflow path when global is true", () => {
50+
const paths = ClineCommand.getSettablePaths({ global: true });
51+
52+
expect(paths).toEqual({
53+
relativeDirPath: join("Documents", "Cline", "Workflows"),
54+
});
55+
});
56+
});
57+
58+
describe("constructor", () => {
59+
it("should create instance with valid content", () => {
60+
const command = new ClineCommand({
61+
baseDir: testDir,
62+
relativeDirPath: ".clinerules/workflows",
63+
relativeFilePath: "test.md",
64+
fileContent: validContent,
65+
validate: true,
66+
});
67+
68+
expect(command).toBeInstanceOf(ClineCommand);
69+
expect(command.getFileContent()).toBe(validContent);
70+
});
71+
72+
it("should allow empty content", () => {
73+
const command = new ClineCommand({
74+
baseDir: testDir,
75+
relativeDirPath: ".clinerules/workflows",
76+
relativeFilePath: "test.md",
77+
fileContent: "",
78+
validate: true,
79+
});
80+
81+
expect(command.getFileContent()).toBe("");
82+
});
83+
});
84+
85+
describe("toRulesyncCommand", () => {
86+
it("should convert to RulesyncCommand", () => {
87+
const clineCommand = new ClineCommand({
88+
baseDir: testDir,
89+
relativeDirPath: ".clinerules/workflows",
90+
relativeFilePath: "test.md",
91+
fileContent: validContent,
92+
validate: true,
93+
});
94+
95+
const rulesyncCommand = clineCommand.toRulesyncCommand();
96+
97+
expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand);
98+
expect(rulesyncCommand.getFrontmatter().targets).toEqual(["*"]);
99+
expect(rulesyncCommand.getBody()).toBe(validContent);
100+
expect(rulesyncCommand.getRelativeDirPath()).toBe(RULESYNC_COMMANDS_RELATIVE_DIR_PATH);
101+
});
102+
});
103+
104+
describe("fromRulesyncCommand", () => {
105+
it("should create ClineCommand from RulesyncCommand", () => {
106+
const rulesyncCommand = new RulesyncCommand({
107+
baseDir: testDir,
108+
relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH,
109+
relativeFilePath: "workflow.md",
110+
frontmatter: { targets: ["cline"], description: "" },
111+
body: validContent,
112+
fileContent: validContent,
113+
validate: true,
114+
});
115+
116+
const clineCommand = ClineCommand.fromRulesyncCommand({
117+
baseDir: testDir,
118+
rulesyncCommand,
119+
});
120+
121+
expect(clineCommand).toBeInstanceOf(ClineCommand);
122+
expect(clineCommand.getRelativeDirPath()).toBe(join(".clinerules", "workflows"));
123+
expect(clineCommand.getFileContent()).toBe(validContent);
124+
});
125+
});
126+
127+
describe("validate", () => {
128+
it("should always pass validation", () => {
129+
const command = new ClineCommand({
130+
baseDir: testDir,
131+
relativeDirPath: ".clinerules/workflows",
132+
relativeFilePath: "test.md",
133+
fileContent: validContent,
134+
validate: true,
135+
});
136+
137+
expect(command.validate().success).toBe(true);
138+
});
139+
});
140+
141+
describe("fromFile", () => {
142+
it("should load and strip frontmatter content", async () => {
143+
const workflowsDir = join(testDir, ".clinerules", "workflows");
144+
const filePath = join(workflowsDir, "workflow.md");
145+
await writeFileContent(filePath, markdownWithFrontmatter);
146+
147+
const command = await ClineCommand.fromFile({
148+
baseDir: testDir,
149+
relativeFilePath: "workflow.md",
150+
});
151+
152+
expect(command).toBeInstanceOf(ClineCommand);
153+
expect(command.getRelativeDirPath()).toBe(join(".clinerules", "workflows"));
154+
expect(command.getFileContent()).toBe("# Workflow\nStep 1");
155+
});
156+
157+
it("should load global workflows when global is true", async () => {
158+
const globalDir = join("Documents", "Cline", "Workflows");
159+
const absoluteGlobalDir = join(testDir, globalDir);
160+
const filePath = join(absoluteGlobalDir, "global.md");
161+
await writeFileContent(filePath, validContent);
162+
163+
const command = await ClineCommand.fromFile({
164+
baseDir: testDir,
165+
relativeFilePath: "global.md",
166+
global: true,
167+
});
168+
169+
expect(command.getRelativeDirPath()).toBe(globalDir);
170+
expect(command.getFileContent()).toBe(validContent);
171+
});
172+
});
173+
174+
describe("isTargetedByRulesyncCommand", () => {
175+
it("should return true when targets include cline", () => {
176+
const rulesyncCommand = new RulesyncCommand({
177+
baseDir: testDir,
178+
relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH,
179+
relativeFilePath: "workflow.md",
180+
frontmatter: { targets: ["cline"], description: "" },
181+
body: validContent,
182+
fileContent: validContent,
183+
validate: true,
184+
});
185+
186+
expect(ClineCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true);
187+
});
188+
189+
it("should return false when targets exclude cline", () => {
190+
const rulesyncCommand = new RulesyncCommand({
191+
baseDir: testDir,
192+
relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH,
193+
relativeFilePath: "workflow.md",
194+
frontmatter: { targets: ["cursor"], description: "" },
195+
body: validContent,
196+
fileContent: validContent,
197+
validate: true,
198+
});
199+
200+
expect(ClineCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(false);
201+
});
202+
});
203+
204+
describe("forDeletion", () => {
205+
it("should create deletable command", () => {
206+
const command = ClineCommand.forDeletion({
207+
baseDir: testDir,
208+
relativeDirPath: ".clinerules/workflows",
209+
relativeFilePath: "obsolete.md",
210+
});
211+
212+
expect(command.isDeletable()).toBe(true);
213+
expect(command.getFileContent()).toBe("");
214+
});
215+
});
216+
});

0 commit comments

Comments
 (0)