Skip to content

refactor: unify SpecKit/OpenSpec command management (Phase 06)#820

Merged
pedramamini merged 3 commits intoRunMaestro:rcfrom
jSydorowicz21:dedup/phase-06-speckit-openspec
Apr 16, 2026
Merged

refactor: unify SpecKit/OpenSpec command management (Phase 06)#820
pedramamini merged 3 commits intoRunMaestro:rcfrom
jSydorowicz21:dedup/phase-06-speckit-openspec

Conversation

@jSydorowicz21
Copy link
Copy Markdown
Contributor

@jSydorowicz21 jSydorowicz21 commented Apr 13, 2026

Summary

Extracts shared command management logic from speckit-manager.ts and openspec-manager.ts into a new src/main/spec-command-manager.ts factory. Both managers now delegate common operations (prompt CRUD, customization tracking, metadata caching) to the shared base while keeping their phase-specific refresh strategies.

Net: -108 lines across 3 files (1003 -> 895)

Shared base

New: src/main/spec-command-manager.ts (313 lines)

  • Factory createSpecCommandManager(config) returning { getMetadata, getPrompts, getCommand, getCommandBySlash, savePrompt, resetPrompt }
  • Types: SpecCommand, SpecMetadata, SpecCommandDefinition
  • Handles on-disk customization tracking, default vs. modified state, slash command lookup

Refactored managers

  • src/main/speckit-manager.ts: 531 -> 319 lines. Keeps SpecKit-specific GitHub release ZIP + unzip refresh strategy for pulling commands from the spec-kit repo.
  • src/main/openspec-manager.ts: 472 -> 261 lines. Keeps OpenSpec-specific AGENTS.md section-parsing refresh strategy for pulling commands from the openspec repo.

All public exports (SpecKitCommand, OpenSpecCommand, getSpeckitMetadata, getOpenSpecMetadata, etc.) are preserved so existing consumers keep working with zero changes.

IPC handlers preserved unchanged

Verified all 12 handlers still registered with identical channel names:

  • speckit:getMetadata, speckit:getPrompts, speckit:getCommand, speckit:savePrompt, speckit:resetPrompt, speckit:refresh
  • openspec:getMetadata, openspec:getPrompts, openspec:getCommand, openspec:savePrompt, openspec:resetPrompt, openspec:refresh

IPC handler files (src/main/ipc/handlers/speckit.ts, openspec.ts) were intentionally NOT unified - they already delegate to their managers, and unifying them would require a generic handler factory that adds boilerplate without meaningful savings, at the cost of making the external IPC channel registration harder to read.

Test plan

  • npm run lint passes clean (all 3 tsconfigs)
  • All SpecKit + OpenSpec tests pass (30 tests across openspec-manager and openspec IPC handlers)
  • SpecKit command picker loads prompts correctly
  • OpenSpec command picker loads prompts correctly
  • Customize a SpecKit prompt, verify it saves to .speckit/ and modified flag updates
  • Customize an OpenSpec prompt, verify it saves to .openspec/ and modified flag updates
  • Reset a customized prompt in both
  • Refresh from GitHub in both (pulls latest release)

Risk

Medium. Both managers' refresh strategies (GitHub ZIP vs. AGENTS.md parsing) are preserved as-is; only CRUD + metadata caching was lifted. Existing integration tests (30 pass) cover the delegation paths.

Summary by CodeRabbit

  • Refactor
    • Consolidated command/prompt management into a shared manager for consistent behavior across command sets.
    • Unified public APIs to delegate to the shared manager, improving bundled prompt resolution, user customization persistence, reset-to-default, and slash-command lookups.
  • Tests
    • Improved tests to more accurately simulate missing-file (ENOENT) filesystem behavior.

Extract shared prompt management logic (metadata load, bundled/user prompt
resolution, customization save/reset, command lookup) into a reusable factory
at src/main/spec-command-manager.ts. Both managers now delegate to this shared
base while keeping their source-specific refresh strategies (SpecKit: release
ZIP + unzip, OpenSpec: AGENTS.md section parsing).

IPC handler names (speckit:*, openspec:*) and all public exports are unchanged,
so preload, renderer services, and tests continue to work without modification.

Files touched:
- src/main/spec-command-manager.ts (new, shared factory)
- src/main/speckit-manager.ts (thin wrapper over shared base + ZIP refresh)
- src/main/openspec-manager.ts (thin wrapper over shared base + AGENTS.md refresh)
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

📝 Walkthrough

Walkthrough

Refactored per-feature prompt/command storage into a shared SpecCommandManager (src/main/spec-command-manager.ts) and rewired openspec-manager.ts and speckit-manager.ts to delegate prompt/metadata retrieval, persistence, and lookup to that manager. Tests updated to simulate ENOENT more accurately.

Changes

Cohort / File(s) Summary
Shared Command Manager
src/main/spec-command-manager.ts
Added SpecCommandManager factory, types, and APIs to centralize bundled vs user prompt resolution, metadata loading, stored customization merging, persistence under Electron userData, prompt reset, and command lookup.
OpenSpec Manager
src/main/openspec-manager.ts
Removed local prompt/metadata storage and path helpers; replaced local interfaces with type aliases to shared types. Exports now delegate to manager (getMetadata, getPrompts, getCommand, getCommandBySlash, savePrompt, resetPrompt). refreshOpenSpecPrompts() updated to use manager path/IO helpers while keeping AGENTS.md extraction logic.
SpecKit / Speckit Manager
src/main/speckit-manager.ts
Replaced in-module prompt/storage logic with delegation to createSpecCommandManager(...). Local interfaces replaced by shared type aliases. Public APIs converted to thin delegations to manager; refresh flow uses manager path/IO helpers.
Tests
src/__tests__/main/openspec-manager.test.ts
Added helper enoent() to produce Node-style ENOENT errors and updated mocks to throw that error for missing-file simulations.
Exports & Types
src/main/...
Multiple public function exports converted from async function declarations to const arrow delegations that call the manager. Several public interfaces replaced by type aliases referencing shared manager types. Bundled command definitions updated to SpecCommandDefinition[] and slash strings handled by manager.

Sequence Diagram(s)

sequenceDiagram
  participant Caller as Caller (open/speckit APIs)
  participant Manager as SpecCommandManager
  participant FS as FileSystem (userData)
  participant Bundle as Bundled Resources

  Caller->>Manager: getPrompts() / getMetadata() / getCommand(id)
  Manager->>FS: loadUserCustomizations(), check user prompt files (*.md)
  alt user override exists
    FS-->>Manager: user prompt content + stored metadata
  else
    Manager->>Bundle: load bundled metadata & prompts (or dev src/prompts)
    Bundle-->>Manager: bundled content
  end
  Manager-->>Caller: resolved prompts/metadata (merged, with isModified flags)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐇
I nibbled through the code all night,
Found prompts and set them right.
One manager now keeps every tune,
Two modules hum beneath the moon.
A tidy hop—now ship by noon!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and accurately summarizes the main change: refactoring to unify SpecKit and OpenSpec command management across a new shared factory module.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jSydorowicz21 jSydorowicz21 self-assigned this Apr 14, 2026
@jSydorowicz21 jSydorowicz21 marked this pull request as ready for review April 14, 2026 04:35
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 14, 2026

Greptile Summary

This PR extracts the common prompt CRUD, customization tracking, and metadata caching logic shared by speckit-manager.ts and openspec-manager.ts into a new createSpecCommandManager factory in spec-command-manager.ts. The refactor is clean — all 12 IPC channel contracts are preserved, behavioral parity is maintained (verified against the diff), and the net reduction is ~108 lines with no functional regressions.

Confidence Score: 5/5

  • Safe to merge — all findings are P2 style suggestions with no correctness or data-integrity impact.
  • The refactoring faithfully preserves all public exports and IPC handler contracts. Both managers delegate correctly to the shared factory, and the refresh strategies (GitHub ZIP for SpecKit, AGENTS.md parsing for OpenSpec) are unchanged. The two P2 findings (unused command field in SpecCommandDefinition, unexported StoredData type) are documentation/ergonomics concerns that don't affect runtime behavior.
  • No files require special attention beyond the P2 style notes on spec-command-manager.ts.

Important Files Changed

Filename Overview
src/main/spec-command-manager.ts New shared factory for spec command management. Well-structured, but StoredData is unexported yet appears in the public SpecCommandManager interface, and the command field on SpecCommandDefinition is declared required but never consumed by any internal logic.
src/main/openspec-manager.ts Clean delegation to createSpecCommandManager; AGENTS.md refresh strategy preserved intact. Public exports are unchanged type aliases over the shared SpecCommand/SpecMetadata.
src/main/speckit-manager.ts Clean delegation to createSpecCommandManager; GitHub release ZIP refresh strategy preserved intact. All public exports maintained as type aliases.

Class Diagram

%%{init: {'theme': 'neutral'}}%%
classDiagram
    class SpecCommandManagerConfig {
        +logContext: string
        +filePrefix: string
        +bundledDirName: string
        +customizationsFileName: string
        +userPromptsDirName: string
        +commands: readonly SpecCommandDefinition[]
        +defaultMetadata: SpecMetadata
    }

    class SpecCommandManager {
        +getMetadata() Promise~SpecMetadata~
        +getPrompts() Promise~SpecCommand[]~
        +savePrompt(id, content) Promise~void~
        +resetPrompt(id) Promise~string~
        +getCommand(id) Promise~SpecCommand~
        +getCommandBySlash(slash) Promise~SpecCommand~
        +getUserPromptsPath() string
        +loadUserCustomizations() Promise~StoredData~
        +saveUserCustomizations(data) Promise~void~
        +getBundledMetadata() Promise~SpecMetadata~
    }

    class createSpecCommandManager {
        <<factory>>
        +createSpecCommandManager(config) SpecCommandManager
    }

    class OpenSpecManager {
        +getOpenSpecMetadata()
        +getOpenSpecPrompts()
        +saveOpenSpecPrompt()
        +resetOpenSpecPrompt()
        +refreshOpenSpecPrompts()
    }

    class SpecKitManager {
        +getSpeckitMetadata()
        +getSpeckitPrompts()
        +saveSpeckitPrompt()
        +resetSpeckitPrompt()
        +refreshSpeckitPrompts()
    }

    createSpecCommandManager --> SpecCommandManager : returns
    SpecCommandManagerConfig --> createSpecCommandManager : config input
    OpenSpecManager --> SpecCommandManager : delegates to manager
    SpecKitManager --> SpecCommandManager : delegates to manager
Loading

Reviews (1): Last reviewed commit: "refactor: unify SpecKit/OpenSpec command..." | Re-trigger Greptile

Comment on lines +20 to +25
export interface SpecCommandDefinition {
id: string;
command: string;
description: string;
isCustom: boolean;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 command field is declared but never consumed

SpecCommandDefinition.command is required by the interface and populated in every command list, but getBundledPrompts() never reads it — id and description are the only fields used. The outgoing SpecCommand.command is always reconstructed as `/${filePrefix}.${id}` (line 248). Keeping the field in the definition creates a silent contract gap: if a definition ever has a command that doesn't match /${filePrefix}.${id}, getCommandBySlash() would find the command via the reconstructed string, making the declared command value misleading.

Consider removing the field from SpecCommandDefinition (it's always derivable) or actually reading it in getPrompts() so the definition is the source of truth.

Suggested change
export interface SpecCommandDefinition {
id: string;
command: string;
description: string;
isCustom: boolean;
}
export interface SpecCommandDefinition {
id: string;
description: string;
isCustom: boolean;
}

Comment on lines +88 to +92
/** Helpers used by refresh implementations. */
getUserPromptsPath(): string;
loadUserCustomizations(): Promise<StoredData | null>;
saveUserCustomizations(data: StoredData): Promise<void>;
getBundledMetadata(): Promise<SpecMetadata>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unexported StoredData type leaked through public interface

loadUserCustomizations and saveUserCustomizations expose StoredData in the SpecCommandManager interface, but StoredData (and StoredPrompt) are not exported. Callers in openspec-manager.ts and speckit-manager.ts must construct objects of this shape without a named type, relying on structural inference. This compiles today (TypeScript is structural), but makes the interface harder to document and use safely — any future field added to StoredData must be hunted down in all callers manually.

Exporting StoredData (and StoredPrompt) would make the contract explicit:

export interface StoredPrompt {
	content: string;
	isModified: boolean;
	modifiedAt?: string;
}

export interface StoredData {
	metadata: SpecMetadata;
	prompts: Record<string, StoredPrompt>;
}

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/spec-command-manager.ts`:
- Around line 113-119: The current loadUserCustomizations() function swallows
all errors; change its catch to inspect the error code and only return null for
ENOENT, otherwise re-throw the error so malformed JSON and permission errors
surface; update the catch in loadUserCustomizations() (and the analogous
user-prompt and metadata fallback functions referenced later) to check err.code
=== 'ENOENT' before returning null and re-throw for any other error.
- Around line 257-270: savePrompt currently accepts any id and persists it,
creating dead/invalid customization state because getPrompts() only knows
configured commands and resetPrompt() throws for unknown ids; update savePrompt
to validate the id before saving by loading the available prompt definitions
(via getPrompts or the bundled metadata from getBundledMetadata()) and
rejecting/throwing if the given id is not present, only proceeding to call
loadUserCustomizations(), modify customizations.prompts[id], and
saveUserCustomizations(customizations) when the id is recognized; keep existing
logging (logger.info) but move it after the validation and successful save and
surface a clear error when id validation fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 964332d9-8e1c-4ca4-9b99-e94a75d51cd6

📥 Commits

Reviewing files that changed from the base of the PR and between 36b677a and dab9bee.

📒 Files selected for processing (3)
  • src/main/openspec-manager.ts
  • src/main/spec-command-manager.ts
  • src/main/speckit-manager.ts

Comment thread src/main/spec-command-manager.ts
Comment on lines +257 to +270
async function savePrompt(id: string, content: string): Promise<void> {
const customizations = (await loadUserCustomizations()) ?? {
metadata: await getBundledMetadata(),
prompts: {},
};

customizations.prompts[id] = {
content,
isModified: true,
modifiedAt: new Date().toISOString(),
};

await saveUserCustomizations(customizations);
logger.info(`Saved customization for ${filePrefix}.${id}`, logContext);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reject unknown command ids before persisting.

savePrompt() will currently report success for any id, but getPrompts() only returns configured commands and resetPrompt() throws for the same invalid id. That leaves dead JSON state behind and makes bad saves look successful.

🧭 Validate the id up front
 async function savePrompt(id: string, content: string): Promise<void> {
+	if (!commands.some((cmd) => cmd.id === id)) {
+		throw new Error(`Unknown ${filePrefix} command: ${id}`);
+	}
+
 	const customizations = (await loadUserCustomizations()) ?? {
 		metadata: await getBundledMetadata(),
 		prompts: {},
 	};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/spec-command-manager.ts` around lines 257 - 270, savePrompt
currently accepts any id and persists it, creating dead/invalid customization
state because getPrompts() only knows configured commands and resetPrompt()
throws for unknown ids; update savePrompt to validate the id before saving by
loading the available prompt definitions (via getPrompts or the bundled metadata
from getBundledMetadata()) and rejecting/throwing if the given id is not
present, only proceeding to call loadUserCustomizations(), modify
customizations.prompts[id], and saveUserCustomizations(customizations) when the
id is recognized; keep existing logging (logger.info) but move it after the
validation and successful save and surface a clear error when id validation
fails.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/main/spec-command-manager.ts (2)

260-273: ⚠️ Potential issue | 🟡 Minor

Reject unknown prompt ids before saving.

savePrompt() still persists arbitrary ids, which leaves dead customization state behind: getPrompts() never surfaces those entries, while resetPrompt() throws for the same invalid id. Validate against commands up front.

🧭 Validate the id before mutating customizations
 async function savePrompt(id: string, content: string): Promise<void> {
+	if (!commands.some((cmd) => cmd.id === id)) {
+		throw new Error(`Unknown ${filePrefix} command: ${id}`);
+	}
+
 	const customizations = (await loadUserCustomizations()) ?? {
 		metadata: await getBundledMetadata(),
 		prompts: {},
 	};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/spec-command-manager.ts` around lines 260 - 273, savePrompt
currently writes any arbitrary id into user customizations which creates dead
entries (getPrompts won't list them and resetPrompt will later throw); before
mutating customizations in savePrompt(id, content) validate that id exists in
the known command set (e.g., the commands registry used by
getPrompts/resetPrompt) and reject/throw (or return an error) for unknown ids.
Locate savePrompt and add an upfront check against the existing commands
collection (the same source used by getPrompts/resetPrompt) so only known prompt
ids are persisted.

144-197: ⚠️ Potential issue | 🟠 Major

Only fall back to the placeholder for missing bundled prompt files.

Both bundled readFile() branches currently turn any fs failure into "Prompt not available", which masks permission/I/O/package errors as if the prompt were simply absent. Keep the placeholder path for ENOENT, but re-throw everything else.

🔧 Narrow the bundled fallback to missing files only
 			if (cmd.isCustom) {
 				try {
 					const promptPath = path.join(bundledPromptsDir, `${filePrefix}.${cmd.id}.md`);
 					const prompt = await fs.readFile(promptPath, 'utf-8');
 					result[cmd.id] = {
 						prompt,
 						description: cmd.description,
 						isCustom: cmd.isCustom,
 					};
-				} catch (error) {
+				} catch (error: unknown) {
+					if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
 					logger.warn(`Failed to load bundled prompt for ${cmd.id}: ${error}`, logContext);
 					result[cmd.id] = {
 						prompt: `# ${cmd.id}\n\nPrompt not available.`,
 						description: cmd.description,
 						isCustom: cmd.isCustom,
 					};
 				}
 				continue;
 			}
@@
 			try {
 				const promptPath = path.join(bundledPromptsDir, `${filePrefix}.${cmd.id}.md`);
 				const prompt = await fs.readFile(promptPath, 'utf-8');
 				result[cmd.id] = {
 					prompt,
 					description: cmd.description,
 					isCustom: cmd.isCustom,
 				};
-			} catch (error) {
+			} catch (error: unknown) {
+				if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
 				logger.warn(`Failed to load bundled prompt for ${cmd.id}: ${error}`, logContext);
 				result[cmd.id] = {
 					prompt: `# ${cmd.id}\n\nPrompt not available.`,
 					description: cmd.description,
 					isCustom: cmd.isCustom,
 				};
 			}

As per coding guidelines, "Do not silently swallow errors. Let unhandled exceptions bubble up to Sentry for error tracking in production."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/spec-command-manager.ts` around lines 144 - 197, The bundled-prompt
catch blocks in the commands loop (the branches handling cmd.isCustom and the
final "Fall back to bundled prompts" section) currently convert any fs error
into the placeholder prompt; change both catch handlers to only swallow ENOENT
by checking (error as NodeJS.ErrnoException).code === 'ENOENT' and then set the
placeholder, but re-throw the error for any other errno so
permission/I/O/package errors are not masked; keep the same logger.warn for
ENOENT cases and ensure the checks reference cmd.id, bundledPromptsDir and the
readFile calls to locate the exact blocks to modify.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/main/spec-command-manager.ts`:
- Around line 260-273: savePrompt currently writes any arbitrary id into user
customizations which creates dead entries (getPrompts won't list them and
resetPrompt will later throw); before mutating customizations in savePrompt(id,
content) validate that id exists in the known command set (e.g., the commands
registry used by getPrompts/resetPrompt) and reject/throw (or return an error)
for unknown ids. Locate savePrompt and add an upfront check against the existing
commands collection (the same source used by getPrompts/resetPrompt) so only
known prompt ids are persisted.
- Around line 144-197: The bundled-prompt catch blocks in the commands loop (the
branches handling cmd.isCustom and the final "Fall back to bundled prompts"
section) currently convert any fs error into the placeholder prompt; change both
catch handlers to only swallow ENOENT by checking (error as
NodeJS.ErrnoException).code === 'ENOENT' and then set the placeholder, but
re-throw the error for any other errno so permission/I/O/package errors are not
masked; keep the same logger.warn for ENOENT cases and ensure the checks
reference cmd.id, bundledPromptsDir and the readFile calls to locate the exact
blocks to modify.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b773fe85-2b0d-47e7-94db-5ce61ef7c27f

📥 Commits

Reviewing files that changed from the base of the PR and between dab9bee and 11003b6.

📒 Files selected for processing (3)
  • src/main/openspec-manager.ts
  • src/main/spec-command-manager.ts
  • src/main/speckit-manager.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/speckit-manager.ts

@jSydorowicz21 jSydorowicz21 added refactor Clean-up needs ready to merge This PR is ready to merge labels Apr 15, 2026
@pedramamini pedramamini merged commit 9f71dd6 into RunMaestro:rc Apr 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready to merge This PR is ready to merge refactor Clean-up needs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants