Skip to content

Commit 1c0d737

Browse files
authored
Merge pull request #28 from TrueNine/dev
Dev
2 parents 341c9b1 + bb010d1 commit 1c0d737

File tree

12 files changed

+156
-36
lines changed

12 files changed

+156
-36
lines changed

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@truenine/memory-sync-cli",
33
"type": "module",
4-
"version": "2026.10219.12050",
4+
"version": "2026.10219.12358",
55
"description": "TrueNine Memory Synchronization CLI",
66
"author": "TrueNine",
77
"license": "AGPL-3.0-only",

cli/public/tnmsc.example.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "2026.10219.12050",
2+
"version": "2026.10219.12358",
33
"workspaceDir": "~/project",
44
"shadowSourceProject": {
55
"name": "tnmsc-shadow",

cli/src/plugins/ClaudeCodeCLIOutputPlugin.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ const GLOBAL_CONFIG_DIR = '.claude'
1010
const RULES_SUBDIR = 'rules'
1111
const RULE_FILE_PREFIX = 'rule-'
1212

13+
/**
14+
* Output plugin for Claude Code CLI.
15+
*
16+
* Outputs rules to `.claude/rules/` directory with frontmatter format.
17+
*
18+
* @see https://github.com/anthropics/claude-code/issues/26868
19+
* Known bug: Claude Code CLI has issues with `.claude/rules` directory handling.
20+
* This may affect rule loading behavior in certain scenarios.
21+
*/
1322
export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin {
1423
constructor() {
1524
super('ClaudeCodeCLIOutputPlugin', {

cli/src/plugins/WindsurfOutputPlugin.test.ts

Lines changed: 111 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
GlobalMemoryPrompt,
44
OutputPluginContext,
55
OutputWriteContext,
6+
RulePrompt,
67
SkillPrompt
78
} from '@/types'
89
import type {RelativePath} from '@/types/FileSystemTypes'
@@ -11,7 +12,7 @@ import * as os from 'node:os'
1112
import * as path from 'node:path'
1213
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
1314
import {createLogger} from '@/log'
14-
import {FilePathKind, PromptKind} from '@/types'
15+
import {FilePathKind, NamingCaseKind, PromptKind} from '@/types'
1516
import {WindsurfOutputPlugin} from './WindsurfOutputPlugin'
1617

1718
function createMockRelativePath(pathStr: string, basePath: string): RelativePath {
@@ -24,14 +25,46 @@ function createMockRelativePath(pathStr: string, basePath: string): RelativePath
2425
}
2526
}
2627

28+
function createMockRulePrompt(
29+
series: string,
30+
ruleName: string,
31+
globs: readonly string[],
32+
scope: 'global' | 'project',
33+
seriName?: string
34+
): RulePrompt {
35+
const content = '# Rule body\n\nFollow this rule.'
36+
return {
37+
type: PromptKind.Rule,
38+
content,
39+
length: content.length,
40+
filePathKind: FilePathKind.Relative,
41+
dir: createMockRelativePath('.', ''),
42+
markdownContents: [],
43+
yamlFrontMatter: {
44+
description: 'Rule description',
45+
globs,
46+
namingCase: NamingCaseKind.KebabCase
47+
},
48+
series,
49+
ruleName,
50+
globs,
51+
scope,
52+
...seriName != null && {seriName}
53+
} as RulePrompt
54+
}
55+
2756
function createMockGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt {
2857
return {
2958
type: PromptKind.GlobalMemory,
3059
content,
3160
length: content.length,
3261
filePathKind: FilePathKind.Relative,
3362
dir: createMockRelativePath('.', basePath),
34-
markdownContents: []
63+
markdownContents: [],
64+
parentDirectoryPath: {
65+
type: 'UserHome',
66+
directory: createMockRelativePath('.codeium/windsurf', basePath)
67+
}
3568
} as GlobalMemoryPrompt
3669
}
3770

@@ -48,7 +81,7 @@ function createMockFastCommandPrompt(
4881
filePathKind: FilePathKind.Relative,
4982
dir: createMockRelativePath('.', basePath),
5083
markdownContents: [],
51-
yamlFrontMatter: {description: 'Fast command'},
84+
yamlFrontMatter: {description: 'Fast command', namingCase: NamingCaseKind.KebabCase},
5285
...series != null && {series},
5386
commandName
5487
} as FastCommandPrompt
@@ -61,15 +94,15 @@ function createMockSkillPrompt(
6194
options?: {childDocs?: {relativePath: string, content: unknown}[], resources?: {relativePath: string, content: string, encoding: 'text' | 'base64'}[]}
6295
): SkillPrompt {
6396
return {
64-
yamlFrontMatter: {name, description: 'A skill'},
97+
yamlFrontMatter: {name, description: 'A skill', namingCase: NamingCaseKind.KebabCase},
6598
dir: createMockRelativePath(name, basePath),
6699
content,
67100
length: content.length,
68101
type: PromptKind.Skill,
69102
filePathKind: FilePathKind.Relative,
70103
markdownContents: [],
71104
...options
72-
} as SkillPrompt
105+
} as unknown as SkillPrompt
73106
}
74107

75108
class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin {
@@ -134,8 +167,8 @@ describe('windsurf output plugin', () => {
134167

135168
const results = await plugin.registerGlobalOutputDirs(ctx)
136169
expect(results).toHaveLength(1)
137-
expect(results[0].path).toBe('global_workflows')
138-
expect(results[0].getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'global_workflows'))
170+
expect(results[0]?.path).toBe('global_workflows')
171+
expect(results[0]?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'global_workflows'))
139172
})
140173

141174
it('should register skills/<skillName> dir when skills exist', async () => {
@@ -148,8 +181,8 @@ describe('windsurf output plugin', () => {
148181

149182
const results = await plugin.registerGlobalOutputDirs(ctx)
150183
expect(results).toHaveLength(1)
151-
expect(results[0].path).toBe(path.join('skills', 'custom-skill'))
152-
expect(results[0].getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'skills', 'custom-skill'))
184+
expect(results[0]?.path).toBe(path.join('skills', 'custom-skill'))
185+
expect(results[0]?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'skills', 'custom-skill'))
153186
})
154187

155188
it('should register both workflows and skills dirs when both exist', async () => {
@@ -320,7 +353,7 @@ describe('windsurf output plugin', () => {
320353

321354
const results = await plugin.writeGlobalOutputs(ctx)
322355
expect(results.files.length).toBeGreaterThanOrEqual(1)
323-
expect(results.files[0].success).toBe(true)
356+
expect(results.files[0]?.success).toBe(true)
324357

325358
const memoryPath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'global_rules.md')
326359
expect(fs.existsSync(memoryPath)).toBe(true)
@@ -442,15 +475,42 @@ describe('windsurf output plugin', () => {
442475

443476
const results = await plugin.writeGlobalOutputs(ctx)
444477
expect(results.files.length).toBeGreaterThanOrEqual(1)
445-
expect(results.files[0].success).toBe(true)
478+
expect(results.files[0]?.success).toBe(true)
446479

447480
const memoryPath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'global_rules.md')
448481
expect(fs.existsSync(memoryPath)).toBe(false)
449482
})
483+
484+
it('should write global rule files with trigger/globs frontmatter', async () => {
485+
const ctx = {
486+
collectedInputContext: {
487+
workspace: {projects: [], directory: createMockRelativePath('.', tempDir)},
488+
skills: [],
489+
fastCommands: [],
490+
rules: [
491+
createMockRulePrompt('test', 'glob', ['src/**/*.ts', '**/*.tsx'], 'global')
492+
]
493+
},
494+
logger: createLogger('test', 'debug'),
495+
dryRun: false
496+
} as unknown as OutputWriteContext
497+
498+
const results = await plugin.writeGlobalOutputs(ctx)
499+
expect(results.files).toHaveLength(1)
500+
501+
const rulePath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'rule-test-glob.md')
502+
expect(fs.existsSync(rulePath)).toBe(true)
503+
504+
const content = fs.readFileSync(rulePath, 'utf8')
505+
expect(content).toContain('trigger: glob')
506+
expect(content).toContain('globs: src/**/*.ts, **/*.tsx')
507+
expect(content).not.toContain('globs: "src/**/*.ts, **/*.tsx"')
508+
expect(content).toContain('Follow this rule.')
509+
})
450510
})
451511

452512
describe('writeProjectOutputs', () => {
453-
it('should return empty results (no project outputs)', async () => {
513+
it('should return empty results when no project rules', async () => {
454514
const ctx = {
455515
collectedInputContext: {
456516
workspace: {projects: [], directory: createMockRelativePath('.', tempDir)},
@@ -464,6 +524,45 @@ describe('windsurf output plugin', () => {
464524
expect(results.files).toHaveLength(0)
465525
expect(results.dirs).toHaveLength(0)
466526
})
527+
528+
it('should write project rules and apply seriName include filter from projectConfig', async () => {
529+
const ctx = {
530+
collectedInputContext: {
531+
workspace: {
532+
projects: [
533+
{
534+
name: 'proj1',
535+
dirFromWorkspacePath: createMockRelativePath('proj1', tempDir),
536+
projectConfig: {rules: {include: ['uniapp']}}
537+
}
538+
],
539+
directory: createMockRelativePath('.', tempDir)
540+
},
541+
rules: [
542+
createMockRulePrompt('test', 'uniapp-only', ['src/**/*.vue'], 'project', 'uniapp'),
543+
createMockRulePrompt('test', 'vue-only', ['src/**/*.ts'], 'project', 'vue')
544+
]
545+
},
546+
logger: createLogger('test', 'debug'),
547+
dryRun: false
548+
} as unknown as OutputWriteContext
549+
550+
const results = await plugin.writeProjectOutputs(ctx)
551+
const outputPaths = results.files.map(file => file.path.path.replaceAll('\\', '/'))
552+
553+
expect(outputPaths.some(p => p.endsWith('rule-test-uniapp-only.md'))).toBe(true)
554+
expect(outputPaths.some(p => p.endsWith('rule-test-vue-only.md'))).toBe(false)
555+
556+
const includedRulePath = path.join(tempDir, 'proj1', '.windsurf', 'rules', 'rule-test-uniapp-only.md')
557+
const excludedRulePath = path.join(tempDir, 'proj1', '.windsurf', 'rules', 'rule-test-vue-only.md')
558+
559+
expect(fs.existsSync(includedRulePath)).toBe(true)
560+
expect(fs.existsSync(excludedRulePath)).toBe(false)
561+
562+
const includedRuleContent = fs.readFileSync(includedRulePath, 'utf8')
563+
expect(includedRuleContent).toContain('trigger: glob')
564+
expect(includedRuleContent).toContain('globs: src/**/*.vue')
565+
})
467566
})
468567

469568
describe('clean support', () => {

cli/src/plugins/WindsurfOutputPlugin.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin {
7070
}
7171
}
7272

73-
const globalRules = rules?.filter(r => r.scope === 'global')
73+
const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global')
7474
if (globalRules == null || globalRules.length === 0) return results
7575

7676
const codeiumDir = this.getCodeiumWindsurfDir()
@@ -105,7 +105,7 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin {
105105
}
106106
}
107107

108-
const globalRules = ctx.collectedInputContext.rules?.filter(r => r.scope === 'global')
108+
const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global')
109109
if (globalRules != null && globalRules.length > 0) {
110110
const codeiumDir = this.getCodeiumWindsurfDir()
111111
const memoriesDir = path.join(codeiumDir, MEMORIES_SUBDIR)
@@ -205,7 +205,7 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin {
205205
}
206206
}
207207

208-
const globalRules = rules?.filter(r => r.scope === 'global')
208+
const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global')
209209
if (globalRules == null || globalRules.length === 0) return {files: fileResults, dirs: dirResults}
210210

211211
const memoriesDir = this.getGlobalMemoriesDir()
@@ -227,7 +227,7 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin {
227227
if (projectDir == null) continue
228228
const projectRules = applySubSeriesGlobPrefix(
229229
filterRulesByProjectConfig(
230-
rules.filter(r => r.scope === 'project'),
230+
rules.filter(r => this.normalizeRuleScope(r) === 'project'),
231231
project.projectConfig
232232
),
233233
project.projectConfig
@@ -255,7 +255,7 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin {
255255
if (projectDir == null) continue
256256
const projectRules = applySubSeriesGlobPrefix(
257257
filterRulesByProjectConfig(
258-
rules.filter(r => r.scope === 'project'),
258+
rules.filter(r => this.normalizeRuleScope(r) === 'project'),
259259
project.projectConfig
260260
),
261261
project.projectConfig
@@ -288,7 +288,7 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin {
288288
if (projectDir == null) continue
289289
const projectRules = applySubSeriesGlobPrefix(
290290
filterRulesByProjectConfig(
291-
rules.filter(r => r.scope === 'project'),
291+
rules.filter(r => this.normalizeRuleScope(r) === 'project'),
292292
project.projectConfig
293293
),
294294
project.projectConfig
@@ -548,14 +548,25 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin {
548548
}
549549

550550
private buildRuleContent(rule: RulePrompt): string {
551-
const description = rule.yamlFrontMatter?.description ?? ''
552-
const patterns = rule.globs.join(', ')
553-
const comment = [
554-
`<!-- Activation: Glob | Patterns: ${patterns} -->`,
555-
`<!-- Description: ${description} -->`,
556-
'<!-- Configure activation mode in Windsurf UI: Customizations > Rules -->'
557-
].join('\n')
558-
return `${comment}\n\n${rule.content}`
551+
const fmData: Record<string, unknown> = {
552+
trigger: 'glob',
553+
globs: rule.globs.length > 0 ? rule.globs.join(', ') : ''
554+
}
555+
556+
const raw = buildMarkdownWithFrontMatter(fmData, rule.content)
557+
const lines = raw.split('\n')
558+
const transformedLines = lines.map(line => {
559+
const match = /^(\s*globs:\s*)(['"])(.*)\2\s*$/.exec(line)
560+
if (match == null) return line
561+
562+
const prefix = match[1] ?? 'globs: '
563+
const value = match[3] ?? ''
564+
if (value.trim().length === 0) return line
565+
566+
return `${prefix}${value}`
567+
})
568+
569+
return transformedLines.join('\n')
559570
}
560571

561572
private async writeRuleFile(

cli/src/utils/ruleFilter.property.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ describe('applySubSeriesGlobPrefix property tests', () => {
196196
)
197197
})
198198

199-
it('should produce same number or more globs when matched', async () => {
199+
it('should produce at least one glob per unique subdir when matched', async () => {
200200
await fc.assert(
201201
fc.asyncProperty(
202202
seriNameGen,
@@ -208,7 +208,8 @@ describe('applySubSeriesGlobPrefix property tests', () => {
208208
for (const subdir of subdirs) subSeries[subdir] = [seriName]
209209
const projectConfig: ProjectConfig = {rules: {subSeries}}
210210
const result = applySubSeriesGlobPrefix(rules, projectConfig)
211-
expect(result[0].globs.length).toBeGreaterThanOrEqual(globs.length)
211+
const uniqueSubdirs = new Set(subdirs).size
212+
expect(result[0].globs.length).toBeGreaterThanOrEqual(uniqueSubdirs)
212213
}
213214
),
214215
{numRuns: 100}

doc/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@truenine/memory-sync-docs",
33
"private": true,
4-
"version": "2026.10219.12050",
4+
"version": "2026.10219.12358",
55
"description": "Documentation site for @truenine/memory-sync, built with Next.js 16 and MDX.",
66
"scripts": {
77
"dev": "next dev",

gui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@truenine/memory-sync-gui",
3-
"version": "2026.10219.12050",
3+
"version": "2026.10219.12358",
44
"private": true,
55
"engines": {
66
"node": ">=25.2.1",

gui/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "memory-sync-gui"
3-
version = "2026.10219.12050"
3+
version = "2026.10219.12358"
44
description = "Memory Sync desktop GUI application"
55
authors = ["TrueNine"]
66
edition = "2021"

gui/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
3-
"version": "2026.10219.12050",
3+
"version": "2026.10219.12358",
44
"productName": "Memory Sync",
55
"identifier": "org.truenine.memory-sync",
66
"build": {

0 commit comments

Comments
 (0)