headlesscode 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
import * as vscode from "../../../shim/vscode"
|
|
2
|
+
import * as path from "path"
|
|
3
|
+
import * as fs from "fs/promises"
|
|
4
|
+
import * as os from "os"
|
|
5
|
+
|
|
6
|
+
import * as yaml from "yaml"
|
|
7
|
+
import stripBom from "../../../shim/strip-bom"
|
|
8
|
+
|
|
9
|
+
import { type ModeConfig, type PromptComponent, customModesSettingsSchema, modeConfigSchema } from "../../../types"
|
|
10
|
+
|
|
11
|
+
import { fileExistsAtPath } from "../../utils/fs"
|
|
12
|
+
import { getWorkspacePath } from "../../utils/path"
|
|
13
|
+
import { getGlobalRooDirectory } from "../../services/roo-config"
|
|
14
|
+
import { logger } from "../../utils/logging"
|
|
15
|
+
import { GlobalFileNames } from "../../shared/globalFileNames"
|
|
16
|
+
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
|
|
17
|
+
import { t } from "../../i18n"
|
|
18
|
+
|
|
19
|
+
const ROOMODES_FILENAME = ".roomodes"
|
|
20
|
+
|
|
21
|
+
// Type definitions for import/export functionality
|
|
22
|
+
interface RuleFile {
|
|
23
|
+
relativePath: string
|
|
24
|
+
content: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ExportedModeConfig extends ModeConfig {
|
|
28
|
+
rulesFiles?: RuleFile[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ImportData {
|
|
32
|
+
customModes: ExportedModeConfig[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ExportResult {
|
|
36
|
+
success: boolean
|
|
37
|
+
yaml?: string
|
|
38
|
+
error?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ImportResult {
|
|
42
|
+
success: boolean
|
|
43
|
+
slug?: string
|
|
44
|
+
error?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class CustomModesManager {
|
|
48
|
+
private static readonly cacheTTL = 10_000
|
|
49
|
+
|
|
50
|
+
private disposables: vscode.Disposable[] = []
|
|
51
|
+
private isWriting = false
|
|
52
|
+
private writeQueue: Array<() => Promise<void>> = []
|
|
53
|
+
private cachedModes: ModeConfig[] | null = null
|
|
54
|
+
private cachedAt: number = 0
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly context: vscode.ExtensionContext,
|
|
58
|
+
private readonly onUpdate: () => Promise<void>,
|
|
59
|
+
) {
|
|
60
|
+
this.watchCustomModesFiles().catch((error) => {
|
|
61
|
+
console.error("[CustomModesManager] Failed to setup file watchers:", error)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async queueWrite(operation: () => Promise<void>): Promise<void> {
|
|
66
|
+
this.writeQueue.push(operation)
|
|
67
|
+
|
|
68
|
+
if (!this.isWriting) {
|
|
69
|
+
await this.processWriteQueue()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async processWriteQueue(): Promise<void> {
|
|
74
|
+
if (this.isWriting || this.writeQueue.length === 0) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.isWriting = true
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
while (this.writeQueue.length > 0) {
|
|
82
|
+
const operation = this.writeQueue.shift()
|
|
83
|
+
|
|
84
|
+
if (operation) {
|
|
85
|
+
await operation()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
this.isWriting = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async getWorkspaceRoomodes(): Promise<string | undefined> {
|
|
94
|
+
const workspaceFolders = vscode.workspace.workspaceFolders
|
|
95
|
+
|
|
96
|
+
if (!workspaceFolders || workspaceFolders.length === 0) {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const workspaceRoot = getWorkspacePath()
|
|
101
|
+
const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
|
|
102
|
+
const exists = await fileExistsAtPath(roomodesPath)
|
|
103
|
+
return exists ? roomodesPath : undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Regex pattern for problematic characters that need to be cleaned from YAML content
|
|
108
|
+
* Includes:
|
|
109
|
+
* - \u00A0: Non-breaking space
|
|
110
|
+
* - \u200B-\u200D: Zero-width spaces and joiners
|
|
111
|
+
* - \u2010-\u2015, \u2212: Various dash characters
|
|
112
|
+
* - \u2018-\u2019: Smart single quotes
|
|
113
|
+
* - \u201C-\u201D: Smart double quotes
|
|
114
|
+
*/
|
|
115
|
+
private static readonly PROBLEMATIC_CHARS_REGEX =
|
|
116
|
+
// eslint-disable-next-line no-misleading-character-class
|
|
117
|
+
/[\u00A0\u200B\u200C\u200D\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u2018\u2019\u201C\u201D]/g
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Clean invisible and problematic characters from YAML content
|
|
121
|
+
*/
|
|
122
|
+
private cleanInvisibleCharacters(content: string): string {
|
|
123
|
+
// Single pass replacement for all problematic characters
|
|
124
|
+
return content.replace(CustomModesManager.PROBLEMATIC_CHARS_REGEX, (match) => {
|
|
125
|
+
switch (match) {
|
|
126
|
+
case "\u00A0": // Non-breaking space
|
|
127
|
+
return " "
|
|
128
|
+
case "\u200B": // Zero-width space
|
|
129
|
+
case "\u200C": // Zero-width non-joiner
|
|
130
|
+
case "\u200D": // Zero-width joiner
|
|
131
|
+
return ""
|
|
132
|
+
case "\u2018": // Left single quotation mark
|
|
133
|
+
case "\u2019": // Right single quotation mark
|
|
134
|
+
return "'"
|
|
135
|
+
case "\u201C": // Left double quotation mark
|
|
136
|
+
case "\u201D": // Right double quotation mark
|
|
137
|
+
return '"'
|
|
138
|
+
default: // Dash characters (U+2010 through U+2015, U+2212)
|
|
139
|
+
return "-"
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse YAML content with enhanced error handling and preprocessing
|
|
146
|
+
*/
|
|
147
|
+
private parseYamlSafely(content: string, filePath: string): any {
|
|
148
|
+
// Clean the content
|
|
149
|
+
let cleanedContent = stripBom(content)
|
|
150
|
+
cleanedContent = this.cleanInvisibleCharacters(cleanedContent)
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const parsed = yaml.parse(cleanedContent)
|
|
154
|
+
// Ensure we never return null or undefined
|
|
155
|
+
return parsed ?? {}
|
|
156
|
+
} catch (yamlError) {
|
|
157
|
+
// For .roomodes files, try JSON as fallback
|
|
158
|
+
if (filePath.endsWith(ROOMODES_FILENAME)) {
|
|
159
|
+
try {
|
|
160
|
+
// Try parsing the original content as JSON (not the cleaned content)
|
|
161
|
+
return JSON.parse(content)
|
|
162
|
+
} catch (jsonError) {
|
|
163
|
+
// JSON also failed, show the original YAML error
|
|
164
|
+
const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError)
|
|
165
|
+
console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg)
|
|
166
|
+
|
|
167
|
+
const lineMatch = errorMsg.match(/at line (\d+)/)
|
|
168
|
+
const line = lineMatch ? lineMatch[1] : "unknown"
|
|
169
|
+
vscode.window.showErrorMessage(t("common:customModes.errors.yamlParseError", { line }))
|
|
170
|
+
|
|
171
|
+
// Return empty object to prevent duplicate error handling
|
|
172
|
+
return {}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// For non-.roomodes files, just log and return empty object
|
|
177
|
+
const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError)
|
|
178
|
+
console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg)
|
|
179
|
+
return {}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async loadModesFromFile(filePath: string): Promise<ModeConfig[]> {
|
|
184
|
+
try {
|
|
185
|
+
const content = await fs.readFile(filePath, "utf-8")
|
|
186
|
+
const settings = this.parseYamlSafely(content, filePath)
|
|
187
|
+
|
|
188
|
+
// Ensure settings has customModes property
|
|
189
|
+
if (!settings || typeof settings !== "object" || !settings.customModes) {
|
|
190
|
+
return []
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = customModesSettingsSchema.safeParse(settings)
|
|
194
|
+
|
|
195
|
+
if (!result.success) {
|
|
196
|
+
console.error(`[CustomModesManager] Schema validation failed for ${filePath}:`, result.error)
|
|
197
|
+
|
|
198
|
+
// Show user-friendly error for .roomodes files
|
|
199
|
+
if (filePath.endsWith(ROOMODES_FILENAME)) {
|
|
200
|
+
const issues = result.error.issues
|
|
201
|
+
.map((issue) => `• ${issue.path.join(".")}: ${issue.message}`)
|
|
202
|
+
.join("\n")
|
|
203
|
+
|
|
204
|
+
vscode.window.showErrorMessage(t("common:customModes.errors.schemaValidationError", { issues }))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return []
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Determine source based on file path
|
|
211
|
+
const isRoomodes = filePath.endsWith(ROOMODES_FILENAME)
|
|
212
|
+
const source = isRoomodes ? ("project" as const) : ("global" as const)
|
|
213
|
+
|
|
214
|
+
// Add source to each mode
|
|
215
|
+
return result.data.customModes.map((mode) => ({ ...mode, source }))
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// Only log if the error wasn't already handled in parseYamlSafely
|
|
218
|
+
if (!(error as any).alreadyHandled) {
|
|
219
|
+
const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
|
220
|
+
console.error(`[CustomModesManager] ${errorMsg}`)
|
|
221
|
+
}
|
|
222
|
+
return []
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise<ModeConfig[]> {
|
|
227
|
+
const slugs = new Set<string>()
|
|
228
|
+
const merged: ModeConfig[] = []
|
|
229
|
+
|
|
230
|
+
// Add project mode (takes precedence)
|
|
231
|
+
for (const mode of projectModes) {
|
|
232
|
+
if (!slugs.has(mode.slug)) {
|
|
233
|
+
slugs.add(mode.slug)
|
|
234
|
+
merged.push({ ...mode, source: "project" })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Add non-duplicate global modes
|
|
239
|
+
for (const mode of globalModes) {
|
|
240
|
+
if (!slugs.has(mode.slug)) {
|
|
241
|
+
slugs.add(mode.slug)
|
|
242
|
+
merged.push({ ...mode, source: "global" })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return merged
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
public async getCustomModesFilePath(): Promise<string> {
|
|
250
|
+
const settingsDir = await ensureSettingsDirectoryExists(this.context)
|
|
251
|
+
const filePath = path.join(settingsDir, GlobalFileNames.customModes)
|
|
252
|
+
const fileExists = await fileExistsAtPath(filePath)
|
|
253
|
+
|
|
254
|
+
if (!fileExists) {
|
|
255
|
+
await this.queueWrite(() => fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 })))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return filePath
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async watchCustomModesFiles(): Promise<void> {
|
|
262
|
+
// Skip if test environment is detected
|
|
263
|
+
if (process.env.NODE_ENV === "test") {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const settingsPath = await this.getCustomModesFilePath()
|
|
268
|
+
|
|
269
|
+
// Watch settings file
|
|
270
|
+
const settingsWatcher = vscode.workspace.createFileSystemWatcher(settingsPath)
|
|
271
|
+
|
|
272
|
+
const handleSettingsChange = async () => {
|
|
273
|
+
try {
|
|
274
|
+
// Ensure that the settings file exists (especially important for delete events)
|
|
275
|
+
await this.getCustomModesFilePath()
|
|
276
|
+
const content = await fs.readFile(settingsPath, "utf-8")
|
|
277
|
+
|
|
278
|
+
const errorMessage = t("common:customModes.errors.invalidFormat")
|
|
279
|
+
|
|
280
|
+
let config: any
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
config = this.parseYamlSafely(content, settingsPath)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
console.error(error)
|
|
286
|
+
vscode.window.showErrorMessage(errorMessage)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const result = customModesSettingsSchema.safeParse(config)
|
|
291
|
+
|
|
292
|
+
if (!result.success) {
|
|
293
|
+
vscode.window.showErrorMessage(errorMessage)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Get modes from .roomodes if it exists (takes precedence)
|
|
298
|
+
const roomodesPath = await this.getWorkspaceRoomodes()
|
|
299
|
+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
|
|
300
|
+
|
|
301
|
+
// Merge modes from both sources (.roomodes takes precedence)
|
|
302
|
+
const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
|
|
303
|
+
await this.context.globalState.update("customModes", mergedModes)
|
|
304
|
+
this.clearCache()
|
|
305
|
+
await this.onUpdate()
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error(`[CustomModesManager] Error handling settings file change:`, error)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this.disposables.push(settingsWatcher.onDidChange(handleSettingsChange))
|
|
312
|
+
this.disposables.push(settingsWatcher.onDidCreate(handleSettingsChange))
|
|
313
|
+
this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange))
|
|
314
|
+
this.disposables.push(settingsWatcher)
|
|
315
|
+
|
|
316
|
+
// Watch .roomodes file - watch the path even if it doesn't exist yet
|
|
317
|
+
const workspaceFolders = vscode.workspace.workspaceFolders
|
|
318
|
+
if (workspaceFolders && workspaceFolders.length > 0) {
|
|
319
|
+
const workspaceRoot = getWorkspacePath()
|
|
320
|
+
const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
|
|
321
|
+
const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath)
|
|
322
|
+
|
|
323
|
+
const handleRoomodesChange = async () => {
|
|
324
|
+
try {
|
|
325
|
+
const settingsModes = await this.loadModesFromFile(settingsPath)
|
|
326
|
+
const roomodesModes = await this.loadModesFromFile(roomodesPath)
|
|
327
|
+
// .roomodes takes precedence
|
|
328
|
+
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
|
|
329
|
+
await this.context.globalState.update("customModes", mergedModes)
|
|
330
|
+
this.clearCache()
|
|
331
|
+
await this.onUpdate()
|
|
332
|
+
} catch (error) {
|
|
333
|
+
console.error(`[CustomModesManager] Error handling .roomodes file change:`, error)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange))
|
|
338
|
+
this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange))
|
|
339
|
+
this.disposables.push(
|
|
340
|
+
roomodesWatcher.onDidDelete(async () => {
|
|
341
|
+
// When .roomodes is deleted, refresh with only settings modes
|
|
342
|
+
try {
|
|
343
|
+
const settingsModes = await this.loadModesFromFile(settingsPath)
|
|
344
|
+
await this.context.globalState.update("customModes", settingsModes)
|
|
345
|
+
this.clearCache()
|
|
346
|
+
await this.onUpdate()
|
|
347
|
+
} catch (error) {
|
|
348
|
+
console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error)
|
|
349
|
+
}
|
|
350
|
+
}),
|
|
351
|
+
)
|
|
352
|
+
this.disposables.push(roomodesWatcher)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
public async getCustomModes(): Promise<ModeConfig[]> {
|
|
357
|
+
// Check if we have a valid cached result.
|
|
358
|
+
const now = Date.now()
|
|
359
|
+
|
|
360
|
+
if (this.cachedModes && now - this.cachedAt < CustomModesManager.cacheTTL) {
|
|
361
|
+
return this.cachedModes
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Get modes from settings file.
|
|
365
|
+
const settingsPath = await this.getCustomModesFilePath()
|
|
366
|
+
const settingsModes = await this.loadModesFromFile(settingsPath)
|
|
367
|
+
|
|
368
|
+
// Get modes from .roomodes if it exists.
|
|
369
|
+
const roomodesPath = await this.getWorkspaceRoomodes()
|
|
370
|
+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
|
|
371
|
+
|
|
372
|
+
// Create maps to store modes by source.
|
|
373
|
+
const projectModes = new Map<string, ModeConfig>()
|
|
374
|
+
const globalModes = new Map<string, ModeConfig>()
|
|
375
|
+
|
|
376
|
+
// Add project modes (they take precedence).
|
|
377
|
+
for (const mode of roomodesModes) {
|
|
378
|
+
projectModes.set(mode.slug, { ...mode, source: "project" as const })
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Add global modes.
|
|
382
|
+
for (const mode of settingsModes) {
|
|
383
|
+
if (!projectModes.has(mode.slug)) {
|
|
384
|
+
globalModes.set(mode.slug, { ...mode, source: "global" as const })
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Combine modes in the correct order: project modes first, then global modes.
|
|
389
|
+
const mergedModes = [
|
|
390
|
+
...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
|
|
391
|
+
...settingsModes
|
|
392
|
+
.filter((mode) => !projectModes.has(mode.slug))
|
|
393
|
+
.map((mode) => ({ ...mode, source: "global" as const })),
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
await this.context.globalState.update("customModes", mergedModes)
|
|
397
|
+
|
|
398
|
+
this.cachedModes = mergedModes
|
|
399
|
+
this.cachedAt = now
|
|
400
|
+
|
|
401
|
+
return mergedModes
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
public async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
|
|
405
|
+
try {
|
|
406
|
+
// Validate the mode configuration before saving
|
|
407
|
+
const validationResult = modeConfigSchema.safeParse(config)
|
|
408
|
+
if (!validationResult.success) {
|
|
409
|
+
const errorMessages = validationResult.error.errors
|
|
410
|
+
.map((err) => `${err.path.join(".")}: ${err.message}`)
|
|
411
|
+
.join(", ")
|
|
412
|
+
const errorMessage = `Invalid mode configuration: ${errorMessages}`
|
|
413
|
+
logger.error("Mode validation failed", { slug, errors: validationResult.error.errors })
|
|
414
|
+
vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage }))
|
|
415
|
+
throw new Error(errorMessage)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const isProjectMode = config.source === "project"
|
|
419
|
+
let targetPath: string
|
|
420
|
+
|
|
421
|
+
if (isProjectMode) {
|
|
422
|
+
const workspaceFolders = vscode.workspace.workspaceFolders
|
|
423
|
+
|
|
424
|
+
if (!workspaceFolders || workspaceFolders.length === 0) {
|
|
425
|
+
logger.error("Failed to update project mode: No workspace folder found", { slug })
|
|
426
|
+
throw new Error(t("common:customModes.errors.noWorkspaceForProject"))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const workspaceRoot = getWorkspacePath()
|
|
430
|
+
targetPath = path.join(workspaceRoot, ROOMODES_FILENAME)
|
|
431
|
+
const exists = await fileExistsAtPath(targetPath)
|
|
432
|
+
|
|
433
|
+
logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, {
|
|
434
|
+
slug,
|
|
435
|
+
workspace: workspaceRoot,
|
|
436
|
+
})
|
|
437
|
+
} else {
|
|
438
|
+
targetPath = await this.getCustomModesFilePath()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
await this.queueWrite(async () => {
|
|
442
|
+
// Ensure source is set correctly based on target file.
|
|
443
|
+
const modeWithSource = {
|
|
444
|
+
...config,
|
|
445
|
+
source: isProjectMode ? ("project" as const) : ("global" as const),
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await this.updateModesInFile(targetPath, (modes) => {
|
|
449
|
+
const updatedModes = modes.filter((m) => m.slug !== slug)
|
|
450
|
+
updatedModes.push(modeWithSource)
|
|
451
|
+
return updatedModes
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
this.clearCache()
|
|
455
|
+
await this.refreshMergedState()
|
|
456
|
+
})
|
|
457
|
+
} catch (error) {
|
|
458
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
459
|
+
logger.error("Failed to update custom mode", { slug, error: errorMessage })
|
|
460
|
+
vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage }))
|
|
461
|
+
throw error
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private async updateModesInFile(filePath: string, operation: (modes: ModeConfig[]) => ModeConfig[]): Promise<void> {
|
|
466
|
+
let content = "{}"
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
content = await fs.readFile(filePath, "utf-8")
|
|
470
|
+
} catch (error) {
|
|
471
|
+
// File might not exist yet.
|
|
472
|
+
content = yaml.stringify({ customModes: [] }, { lineWidth: 0 })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let settings
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
settings = this.parseYamlSafely(content, filePath)
|
|
479
|
+
} catch (error) {
|
|
480
|
+
// Error already logged in parseYamlSafely
|
|
481
|
+
settings = { customModes: [] }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Ensure settings is an object and has customModes property
|
|
485
|
+
if (!settings || typeof settings !== "object") {
|
|
486
|
+
settings = { customModes: [] }
|
|
487
|
+
}
|
|
488
|
+
if (!settings.customModes) {
|
|
489
|
+
settings.customModes = []
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
settings.customModes = operation(settings.customModes)
|
|
493
|
+
await fs.writeFile(filePath, yaml.stringify(settings, { lineWidth: 0 }), "utf-8")
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private async refreshMergedState(): Promise<void> {
|
|
497
|
+
const settingsPath = await this.getCustomModesFilePath()
|
|
498
|
+
const roomodesPath = await this.getWorkspaceRoomodes()
|
|
499
|
+
|
|
500
|
+
const settingsModes = await this.loadModesFromFile(settingsPath)
|
|
501
|
+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
|
|
502
|
+
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
|
|
503
|
+
|
|
504
|
+
await this.context.globalState.update("customModes", mergedModes)
|
|
505
|
+
|
|
506
|
+
this.clearCache()
|
|
507
|
+
|
|
508
|
+
await this.onUpdate()
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
public async deleteCustomMode(slug: string, fromMarketplace = false): Promise<void> {
|
|
512
|
+
try {
|
|
513
|
+
const settingsPath = await this.getCustomModesFilePath()
|
|
514
|
+
const roomodesPath = await this.getWorkspaceRoomodes()
|
|
515
|
+
|
|
516
|
+
const settingsModes = await this.loadModesFromFile(settingsPath)
|
|
517
|
+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
|
|
518
|
+
|
|
519
|
+
// Find the mode in either file
|
|
520
|
+
const projectMode = roomodesModes.find((m) => m.slug === slug)
|
|
521
|
+
const globalMode = settingsModes.find((m) => m.slug === slug)
|
|
522
|
+
|
|
523
|
+
if (!projectMode && !globalMode) {
|
|
524
|
+
throw new Error(t("common:customModes.errors.modeNotFound"))
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Determine which mode to use for rules folder path calculation
|
|
528
|
+
const modeToDelete = projectMode || globalMode
|
|
529
|
+
|
|
530
|
+
await this.queueWrite(async () => {
|
|
531
|
+
// Delete from project first if it exists there
|
|
532
|
+
if (projectMode && roomodesPath) {
|
|
533
|
+
await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug))
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Delete from global settings if it exists there
|
|
537
|
+
if (globalMode) {
|
|
538
|
+
await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug))
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Delete associated rules folder
|
|
542
|
+
if (modeToDelete) {
|
|
543
|
+
await this.deleteRulesFolder(slug, modeToDelete, fromMarketplace)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Clear cache when modes are deleted
|
|
547
|
+
this.clearCache()
|
|
548
|
+
await this.refreshMergedState()
|
|
549
|
+
})
|
|
550
|
+
} catch (error) {
|
|
551
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
552
|
+
vscode.window.showErrorMessage(t("common:customModes.errors.deleteFailed", { error: errorMessage }))
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Deletes the rules folder for a specific mode
|
|
558
|
+
* @param slug - The mode slug
|
|
559
|
+
* @param mode - The mode configuration to determine the scope
|
|
560
|
+
*/
|
|
561
|
+
private async deleteRulesFolder(slug: string, mode: ModeConfig, fromMarketplace = false): Promise<void> {
|
|
562
|
+
try {
|
|
563
|
+
// Determine the scope based on source (project or global)
|
|
564
|
+
const scope = mode.source || "global"
|
|
565
|
+
|
|
566
|
+
// Determine the rules folder path
|
|
567
|
+
let rulesFolderPath: string
|
|
568
|
+
if (scope === "project") {
|
|
569
|
+
const workspacePath = getWorkspacePath()
|
|
570
|
+
if (workspacePath) {
|
|
571
|
+
rulesFolderPath = path.join(workspacePath, ".roo", `rules-${slug}`)
|
|
572
|
+
} else {
|
|
573
|
+
return // No workspace, can't delete project rules
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
// Global scope - use OS home directory
|
|
577
|
+
const homeDir = os.homedir()
|
|
578
|
+
rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Check if the rules folder exists and delete it
|
|
582
|
+
const rulesFolderExists = await fileExistsAtPath(rulesFolderPath)
|
|
583
|
+
if (rulesFolderExists) {
|
|
584
|
+
try {
|
|
585
|
+
await fs.rm(rulesFolderPath, { recursive: true, force: true })
|
|
586
|
+
logger.info(`Deleted rules folder for mode ${slug}: ${rulesFolderPath}`)
|
|
587
|
+
} catch (error) {
|
|
588
|
+
logger.error(`Failed to delete rules folder for mode ${slug}: ${error}`)
|
|
589
|
+
// Notify the user about the failure
|
|
590
|
+
const messageKey = fromMarketplace
|
|
591
|
+
? "common:marketplace.mode.rulesCleanupFailed"
|
|
592
|
+
: "common:customModes.errors.rulesCleanupFailed"
|
|
593
|
+
vscode.window.showWarningMessage(t(messageKey, { rulesFolderPath }))
|
|
594
|
+
// Continue even if folder deletion fails
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
} catch (error) {
|
|
598
|
+
logger.error(`Error deleting rules folder for mode ${slug}`, {
|
|
599
|
+
error: error instanceof Error ? error.message : String(error),
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
public async resetCustomModes(): Promise<void> {
|
|
605
|
+
try {
|
|
606
|
+
const filePath = await this.getCustomModesFilePath()
|
|
607
|
+
await fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 }))
|
|
608
|
+
await this.context.globalState.update("customModes", [])
|
|
609
|
+
this.clearCache()
|
|
610
|
+
await this.onUpdate()
|
|
611
|
+
} catch (error) {
|
|
612
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
613
|
+
vscode.window.showErrorMessage(t("common:customModes.errors.resetFailed", { error: errorMessage }))
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Checks if a mode has associated rules files in the .roo/rules-{slug}/ directory
|
|
619
|
+
* @param slug - The mode identifier to check
|
|
620
|
+
* @returns True if the mode has rules files with content, false otherwise
|
|
621
|
+
*/
|
|
622
|
+
public async checkRulesDirectoryHasContent(slug: string): Promise<boolean> {
|
|
623
|
+
try {
|
|
624
|
+
// First, find the mode to determine its source
|
|
625
|
+
const allModes = await this.getCustomModes()
|
|
626
|
+
const mode = allModes.find((m) => m.slug === slug)
|
|
627
|
+
|
|
628
|
+
if (!mode) {
|
|
629
|
+
// If not in custom modes, check if it's in .roomodes (project-specific)
|
|
630
|
+
const workspacePath = getWorkspacePath()
|
|
631
|
+
if (!workspacePath) {
|
|
632
|
+
return false
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
|
|
636
|
+
try {
|
|
637
|
+
const roomodesExists = await fileExistsAtPath(roomodesPath)
|
|
638
|
+
if (roomodesExists) {
|
|
639
|
+
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
|
|
640
|
+
const roomodesData = yaml.parse(roomodesContent)
|
|
641
|
+
const roomodesModes = roomodesData?.customModes || []
|
|
642
|
+
|
|
643
|
+
// Check if this specific mode exists in .roomodes
|
|
644
|
+
const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug)
|
|
645
|
+
if (!modeInRoomodes) {
|
|
646
|
+
return false // Mode not found anywhere
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
return false // No .roomodes file and not in custom modes
|
|
650
|
+
}
|
|
651
|
+
} catch (error) {
|
|
652
|
+
return false // Cannot read .roomodes and not in custom modes
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Determine the correct rules directory based on mode source
|
|
657
|
+
let modeRulesDir: string
|
|
658
|
+
const isGlobalMode = mode?.source === "global"
|
|
659
|
+
|
|
660
|
+
if (isGlobalMode) {
|
|
661
|
+
// For global modes, check in global .roo directory
|
|
662
|
+
const globalRooDir = getGlobalRooDirectory()
|
|
663
|
+
modeRulesDir = path.join(globalRooDir, `rules-${slug}`)
|
|
664
|
+
} else {
|
|
665
|
+
// For project modes, check in workspace .roo directory
|
|
666
|
+
const workspacePath = getWorkspacePath()
|
|
667
|
+
if (!workspacePath) {
|
|
668
|
+
return false
|
|
669
|
+
}
|
|
670
|
+
modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
const stats = await fs.stat(modeRulesDir)
|
|
675
|
+
if (!stats.isDirectory()) {
|
|
676
|
+
return false
|
|
677
|
+
}
|
|
678
|
+
} catch (error) {
|
|
679
|
+
return false
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Check if directory has any content files
|
|
683
|
+
try {
|
|
684
|
+
const entries = await fs.readdir(modeRulesDir, { withFileTypes: true })
|
|
685
|
+
|
|
686
|
+
for (const entry of entries) {
|
|
687
|
+
if (entry.isFile()) {
|
|
688
|
+
// Use path.join with modeRulesDir and entry.name for compatibility
|
|
689
|
+
const filePath = path.join(modeRulesDir, entry.name)
|
|
690
|
+
const content = await fs.readFile(filePath, "utf-8")
|
|
691
|
+
if (content.trim()) {
|
|
692
|
+
return true // Found at least one file with content
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return false // No files with content found
|
|
698
|
+
} catch (error) {
|
|
699
|
+
return false
|
|
700
|
+
}
|
|
701
|
+
} catch (error) {
|
|
702
|
+
logger.error("Failed to check rules directory for mode", {
|
|
703
|
+
slug,
|
|
704
|
+
error: error instanceof Error ? error.message : String(error),
|
|
705
|
+
})
|
|
706
|
+
return false
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Exports a mode configuration with its associated rules files into a shareable YAML format
|
|
712
|
+
* @param slug - The mode identifier to export
|
|
713
|
+
* @param customPrompts - Optional custom prompts to merge into the export
|
|
714
|
+
* @returns Success status with YAML content or error message
|
|
715
|
+
*/
|
|
716
|
+
public async exportModeWithRules(slug: string, customPrompts?: PromptComponent): Promise<ExportResult> {
|
|
717
|
+
try {
|
|
718
|
+
// Import modes from shared to check built-in modes
|
|
719
|
+
const { modes: builtInModes } = await import("../../shared/modes")
|
|
720
|
+
|
|
721
|
+
// Get all current modes
|
|
722
|
+
const allModes = await this.getCustomModes()
|
|
723
|
+
let mode = allModes.find((m) => m.slug === slug)
|
|
724
|
+
|
|
725
|
+
// If mode not found in custom modes, check if it's a built-in mode that has been customized
|
|
726
|
+
if (!mode) {
|
|
727
|
+
// Only check workspace-based modes if workspace is available
|
|
728
|
+
const workspacePath = getWorkspacePath()
|
|
729
|
+
if (workspacePath) {
|
|
730
|
+
const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
|
|
731
|
+
try {
|
|
732
|
+
const roomodesExists = await fileExistsAtPath(roomodesPath)
|
|
733
|
+
if (roomodesExists) {
|
|
734
|
+
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
|
|
735
|
+
const roomodesData = yaml.parse(roomodesContent)
|
|
736
|
+
const roomodesModes = roomodesData?.customModes || []
|
|
737
|
+
|
|
738
|
+
// Find the mode in .roomodes
|
|
739
|
+
mode = roomodesModes.find((m: any) => m.slug === slug)
|
|
740
|
+
}
|
|
741
|
+
} catch (error) {
|
|
742
|
+
// Continue to check built-in modes
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// If still not found, check if it's a built-in mode
|
|
747
|
+
if (!mode) {
|
|
748
|
+
const builtInMode = builtInModes.find((m) => m.slug === slug)
|
|
749
|
+
if (builtInMode) {
|
|
750
|
+
// Use the built-in mode as the base
|
|
751
|
+
mode = { ...builtInMode }
|
|
752
|
+
} else {
|
|
753
|
+
return { success: false, error: "Mode not found" }
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Determine the base directory based on mode source
|
|
759
|
+
const isGlobalMode = mode.source === "global"
|
|
760
|
+
let baseDir: string
|
|
761
|
+
if (isGlobalMode) {
|
|
762
|
+
// For global modes, use the global .roo directory
|
|
763
|
+
baseDir = getGlobalRooDirectory()
|
|
764
|
+
} else {
|
|
765
|
+
// For project modes, use the workspace directory
|
|
766
|
+
const workspacePath = getWorkspacePath()
|
|
767
|
+
if (!workspacePath) {
|
|
768
|
+
return { success: false, error: "No workspace found" }
|
|
769
|
+
}
|
|
770
|
+
baseDir = workspacePath
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Check for .roo/rules-{slug}/ directory (or rules-{slug}/ for global)
|
|
774
|
+
const modeRulesDir = isGlobalMode
|
|
775
|
+
? path.join(baseDir, `rules-${slug}`)
|
|
776
|
+
: path.join(baseDir, ".roo", `rules-${slug}`)
|
|
777
|
+
|
|
778
|
+
const rulesFiles: RuleFile[] = []
|
|
779
|
+
try {
|
|
780
|
+
const stats = await fs.stat(modeRulesDir)
|
|
781
|
+
if (stats.isDirectory()) {
|
|
782
|
+
// Extract content specific to this mode by looking for the mode-specific rules
|
|
783
|
+
const entries = await fs.readdir(modeRulesDir, { withFileTypes: true })
|
|
784
|
+
|
|
785
|
+
for (const entry of entries) {
|
|
786
|
+
if (entry.isFile()) {
|
|
787
|
+
// Use path.join with modeRulesDir and entry.name for compatibility
|
|
788
|
+
const filePath = path.join(modeRulesDir, entry.name)
|
|
789
|
+
const content = await fs.readFile(filePath, "utf-8")
|
|
790
|
+
if (content.trim()) {
|
|
791
|
+
// Calculate relative path from within the rules directory
|
|
792
|
+
// This excludes the rules-{slug} folder from the path
|
|
793
|
+
const relativePath = path.relative(modeRulesDir, filePath)
|
|
794
|
+
// Normalize path to use forward slashes for cross-platform compatibility
|
|
795
|
+
const normalizedRelativePath = relativePath.replace(/\\/g, "/")
|
|
796
|
+
rulesFiles.push({ relativePath: normalizedRelativePath, content: content.trim() })
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
} catch (error) {
|
|
802
|
+
// Directory doesn't exist, which is fine - mode might not have rules
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Create an export mode with rules files preserved
|
|
806
|
+
const exportMode: ExportedModeConfig = {
|
|
807
|
+
...mode,
|
|
808
|
+
// Remove source property for export
|
|
809
|
+
source: "project" as const,
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Merge custom prompts if provided
|
|
813
|
+
if (customPrompts) {
|
|
814
|
+
if (customPrompts.roleDefinition) exportMode.roleDefinition = customPrompts.roleDefinition
|
|
815
|
+
if (customPrompts.description) exportMode.description = customPrompts.description
|
|
816
|
+
if (customPrompts.whenToUse) exportMode.whenToUse = customPrompts.whenToUse
|
|
817
|
+
if (customPrompts.customInstructions) exportMode.customInstructions = customPrompts.customInstructions
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Add rules files if any exist
|
|
821
|
+
if (rulesFiles.length > 0) {
|
|
822
|
+
exportMode.rulesFiles = rulesFiles
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Generate YAML
|
|
826
|
+
const exportData = {
|
|
827
|
+
customModes: [exportMode],
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const yamlContent = yaml.stringify(exportData)
|
|
831
|
+
|
|
832
|
+
return { success: true, yaml: yamlContent }
|
|
833
|
+
} catch (error) {
|
|
834
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
835
|
+
logger.error("Failed to export mode with rules", { slug, error: errorMessage })
|
|
836
|
+
return { success: false, error: errorMessage }
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Helper method to import rules files for a mode
|
|
842
|
+
* @param importMode - The mode being imported
|
|
843
|
+
* @param rulesFiles - The rules files to import
|
|
844
|
+
* @param source - The import source ("global" or "project")
|
|
845
|
+
*/
|
|
846
|
+
private async importRulesFiles(
|
|
847
|
+
importMode: ExportedModeConfig,
|
|
848
|
+
rulesFiles: RuleFile[],
|
|
849
|
+
source: "global" | "project",
|
|
850
|
+
): Promise<void> {
|
|
851
|
+
// Determine base directory and rules folder path based on source
|
|
852
|
+
let baseDir: string
|
|
853
|
+
let rulesFolderPath: string
|
|
854
|
+
|
|
855
|
+
if (source === "global") {
|
|
856
|
+
baseDir = getGlobalRooDirectory()
|
|
857
|
+
rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
|
|
858
|
+
} else {
|
|
859
|
+
const workspacePath = getWorkspacePath()
|
|
860
|
+
baseDir = path.join(workspacePath, ".roo")
|
|
861
|
+
rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Always remove the existing rules folder for this mode if it exists
|
|
865
|
+
// This ensures that if the imported mode has no rules, the folder is cleaned up
|
|
866
|
+
try {
|
|
867
|
+
await fs.rm(rulesFolderPath, { recursive: true, force: true })
|
|
868
|
+
logger.info(`Removed existing ${source} rules folder for mode ${importMode.slug}`)
|
|
869
|
+
} catch (error) {
|
|
870
|
+
// It's okay if the folder doesn't exist
|
|
871
|
+
logger.debug(`No existing ${source} rules folder to remove for mode ${importMode.slug}`)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Only proceed with file creation if there are rules files to import
|
|
875
|
+
if (!rulesFiles || !Array.isArray(rulesFiles) || rulesFiles.length === 0) {
|
|
876
|
+
return
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Import the new rules files with path validation
|
|
880
|
+
for (const ruleFile of rulesFiles) {
|
|
881
|
+
if (ruleFile.relativePath && ruleFile.content) {
|
|
882
|
+
// Validate the relative path to prevent path traversal attacks
|
|
883
|
+
const normalizedRelativePath = path.normalize(ruleFile.relativePath)
|
|
884
|
+
|
|
885
|
+
// Ensure the path doesn't contain traversal sequences
|
|
886
|
+
if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
|
|
887
|
+
logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
|
|
888
|
+
continue // Skip this file but continue with others
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Check if path starts with a rules-* folder (old export format)
|
|
892
|
+
let cleanedRelativePath = normalizedRelativePath
|
|
893
|
+
const rulesMatch = normalizedRelativePath.match(/^rules-[^\/\\]+[\/\\]/)
|
|
894
|
+
if (rulesMatch) {
|
|
895
|
+
// Strip the entire rules-* folder reference for backwards compatibility
|
|
896
|
+
cleanedRelativePath = normalizedRelativePath.substring(rulesMatch[0].length)
|
|
897
|
+
logger.info(`Detected old export format, stripping ${rulesMatch[0]} from path`)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Use the rules folder path instead of base directory
|
|
901
|
+
const targetPath = path.join(rulesFolderPath, cleanedRelativePath)
|
|
902
|
+
const normalizedTargetPath = path.normalize(targetPath)
|
|
903
|
+
const expectedBasePath = path.normalize(rulesFolderPath)
|
|
904
|
+
|
|
905
|
+
// Ensure the resolved path stays within the rules folder
|
|
906
|
+
if (!normalizedTargetPath.startsWith(expectedBasePath)) {
|
|
907
|
+
logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
|
|
908
|
+
continue // Skip this file but continue with others
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Ensure directory exists
|
|
912
|
+
const targetDir = path.dirname(targetPath)
|
|
913
|
+
await fs.mkdir(targetDir, { recursive: true })
|
|
914
|
+
|
|
915
|
+
// Write the file
|
|
916
|
+
await fs.writeFile(targetPath, ruleFile.content, "utf-8")
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Imports modes from YAML content, including their associated rules files
|
|
923
|
+
* @param yamlContent - The YAML content containing mode configurations
|
|
924
|
+
* @param source - Target level for import: "global" (all projects) or "project" (current workspace only)
|
|
925
|
+
* @returns Success status with optional error message
|
|
926
|
+
*/
|
|
927
|
+
public async importModeWithRules(
|
|
928
|
+
yamlContent: string,
|
|
929
|
+
source: "global" | "project" = "project",
|
|
930
|
+
): Promise<ImportResult> {
|
|
931
|
+
try {
|
|
932
|
+
// Parse the YAML content with proper type validation
|
|
933
|
+
let importData: ImportData
|
|
934
|
+
try {
|
|
935
|
+
const parsed = yaml.parse(yamlContent)
|
|
936
|
+
|
|
937
|
+
// Validate the structure
|
|
938
|
+
if (!parsed?.customModes || !Array.isArray(parsed.customModes) || parsed.customModes.length === 0) {
|
|
939
|
+
return { success: false, error: "Invalid import format: Expected 'customModes' array in YAML" }
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
importData = parsed as ImportData
|
|
943
|
+
} catch (parseError) {
|
|
944
|
+
return {
|
|
945
|
+
success: false,
|
|
946
|
+
error: `Invalid YAML format: ${parseError instanceof Error ? parseError.message : "Failed to parse YAML"}`,
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Check workspace availability early if importing at project level
|
|
951
|
+
if (source === "project") {
|
|
952
|
+
const workspacePath = getWorkspacePath()
|
|
953
|
+
if (!workspacePath) {
|
|
954
|
+
return { success: false, error: "No workspace found" }
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Process each mode in the import
|
|
959
|
+
for (const importMode of importData.customModes) {
|
|
960
|
+
const { rulesFiles, ...modeConfig } = importMode
|
|
961
|
+
|
|
962
|
+
// Validate the mode configuration
|
|
963
|
+
const validationResult = modeConfigSchema.safeParse(modeConfig)
|
|
964
|
+
if (!validationResult.success) {
|
|
965
|
+
logger.error(`Invalid mode configuration for ${modeConfig.slug}`, {
|
|
966
|
+
errors: validationResult.error.errors,
|
|
967
|
+
})
|
|
968
|
+
return {
|
|
969
|
+
success: false,
|
|
970
|
+
error: `Invalid mode configuration for ${modeConfig.slug}: ${validationResult.error.errors.map((e) => e.message).join(", ")}`,
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Check for existing mode conflicts
|
|
975
|
+
const existingModes = await this.getCustomModes()
|
|
976
|
+
const existingMode = existingModes.find((m) => m.slug === importMode.slug)
|
|
977
|
+
if (existingMode) {
|
|
978
|
+
logger.info(`Overwriting existing mode: ${importMode.slug}`)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Import the mode configuration with the specified source
|
|
982
|
+
await this.updateCustomMode(importMode.slug, {
|
|
983
|
+
...modeConfig,
|
|
984
|
+
source: source, // Use the provided source parameter
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
// Import rules files (this also handles cleanup of existing rules folders)
|
|
988
|
+
await this.importRulesFiles(importMode, rulesFiles || [], source)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Refresh the modes after import
|
|
992
|
+
await this.refreshMergedState()
|
|
993
|
+
|
|
994
|
+
// Return the imported mode's slug so the UI can activate it
|
|
995
|
+
return { success: true, slug: importData.customModes[0]?.slug }
|
|
996
|
+
} catch (error) {
|
|
997
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
998
|
+
logger.error("Failed to import mode with rules", { error: errorMessage })
|
|
999
|
+
return { success: false, error: errorMessage }
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
private clearCache(): void {
|
|
1004
|
+
this.cachedModes = null
|
|
1005
|
+
this.cachedAt = 0
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
dispose(): void {
|
|
1009
|
+
for (const disposable of this.disposables) {
|
|
1010
|
+
disposable.dispose()
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
this.disposables = []
|
|
1014
|
+
}
|
|
1015
|
+
}
|