-
Notifications
You must be signed in to change notification settings - Fork 91
Support Factory Droid #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
joeldierkes
merged 4 commits into
mixedbread-ai:main
from
hungthai1401:feature/factory-droid
Nov 28, 2025
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
fa84856
feat: add install and uninstall commands for Factory Droid
hungthai1401 7582562
fix: improve uninstall logic to remove specific hooks and clean up se…
hungthai1401 4bbf721
feat: add installation command for Factory Droid in README
hungthai1401 a228d6c
fix: enhance error handling and type safety in droid installation logic
hungthai1401 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
| import { Command } from "commander"; | ||
| import { ensureAuthenticated } from "../utils"; | ||
|
|
||
| const PLUGIN_ROOT = | ||
| process.env.DROID_PLUGIN_ROOT || | ||
| path.resolve(__dirname, "../../plugins/mgrep"); | ||
| const PLUGIN_HOOKS_DIR = path.join(PLUGIN_ROOT, "hooks"); | ||
| const PLUGIN_SKILL_PATH = path.join(PLUGIN_ROOT, "skills", "mgrep", "SKILL.md"); | ||
|
|
||
| type HookCommand = { | ||
| type: "command"; | ||
| command: string; | ||
| timeout: number; | ||
| }; | ||
|
|
||
| type HookEntry = { | ||
| matcher?: string | null; | ||
| hooks: HookCommand[]; | ||
| }; | ||
|
|
||
| type HooksConfig = Record<string, HookEntry[]>; | ||
|
|
||
| type Settings = { | ||
| hooks?: HooksConfig; | ||
| enableHooks?: boolean; | ||
| allowBackgroundProcesses?: boolean; | ||
| } & Record<string, unknown>; | ||
|
|
||
| function resolveDroidRoot(): string { | ||
| const root = path.join(os.homedir(), ".factory"); | ||
| if (!fs.existsSync(root)) { | ||
| throw new Error( | ||
| `Factory Droid directory not found at ${root}. Start Factory Droid once to initialize it, then re-run the install.`, | ||
| ); | ||
| } | ||
| return root; | ||
| } | ||
|
|
||
| function writeFileIfChanged(filePath: string, content: string): void { | ||
| fs.mkdirSync(path.dirname(filePath), { recursive: true }); | ||
| const already = fs.existsSync(filePath) | ||
| ? fs.readFileSync(filePath, "utf-8") | ||
| : undefined; | ||
| if (already !== content) { | ||
| fs.writeFileSync(filePath, content); | ||
| } | ||
| } | ||
|
|
||
| function readPluginAsset(assetPath: string): string { | ||
| if (!fs.existsSync(assetPath)) { | ||
| throw new Error(`Plugin asset missing: ${assetPath}`); | ||
| } | ||
| return fs.readFileSync(assetPath, "utf-8"); | ||
| } | ||
|
|
||
| function parseJsonWithComments(content: string): Record<string, unknown> { | ||
| const stripped = content | ||
| .split("\n") | ||
| .map((line) => line.replace(/^\s*\/\/.*$/, "")) | ||
| .join("\n"); | ||
| const parsed: unknown = JSON.parse(stripped); | ||
| if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| throw new Error("Factory Droid settings must be a JSON object"); | ||
| } | ||
| return parsed as Record<string, unknown>; | ||
| } | ||
|
|
||
| function loadSettings(settingsPath: string): Settings { | ||
| if (!fs.existsSync(settingsPath)) { | ||
| return {}; | ||
| } | ||
| const raw = fs.readFileSync(settingsPath, "utf-8"); | ||
| const parsed = parseJsonWithComments(raw); | ||
| return parsed as Settings; | ||
| } | ||
|
|
||
| function saveSettings( | ||
| settingsPath: string, | ||
| settings: Record<string, unknown>, | ||
| ): void { | ||
| fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); | ||
| fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); | ||
| } | ||
|
|
||
| function isHooksConfig(value: unknown): value is HooksConfig { | ||
| if (!value || typeof value !== "object" || Array.isArray(value)) { | ||
| return false; | ||
| } | ||
|
|
||
| return Object.values(value).every((entry) => Array.isArray(entry)); | ||
| } | ||
|
|
||
| function mergeHooks( | ||
| existingHooks: HooksConfig | undefined, | ||
| newHooks: HooksConfig, | ||
| ): HooksConfig { | ||
| const merged: HooksConfig = existingHooks | ||
| ? (JSON.parse(JSON.stringify(existingHooks)) as HooksConfig) | ||
| : {}; | ||
| for (const [event, entries] of Object.entries(newHooks)) { | ||
| const current: HookEntry[] = Array.isArray(merged[event]) | ||
| ? merged[event] | ||
| : []; | ||
| for (const entry of entries) { | ||
| const command = entry?.hooks?.[0]?.command; | ||
| const matcher = entry?.matcher ?? null; | ||
| const duplicate = current.some( | ||
| (item) => | ||
| (item?.matcher ?? null) === matcher && | ||
| item?.hooks?.[0]?.command === command && | ||
| item?.hooks?.[0]?.type === entry?.hooks?.[0]?.type, | ||
| ); | ||
hungthai1401 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (!duplicate) { | ||
| current.push(entry); | ||
| } | ||
| } | ||
| merged[event] = current; | ||
| } | ||
| return merged; | ||
| } | ||
|
|
||
| async function installPlugin() { | ||
| const root = resolveDroidRoot(); | ||
| const hooksDir = path.join(root, "hooks", "mgrep"); | ||
| const skillsDir = path.join(root, "skills", "mgrep"); | ||
| const settingsPath = path.join(root, "settings.json"); | ||
|
|
||
| const watchHook = readPluginAsset( | ||
| path.join(PLUGIN_HOOKS_DIR, "mgrep_watch.py"), | ||
| ); | ||
| const killHook = readPluginAsset( | ||
| path.join(PLUGIN_HOOKS_DIR, "mgrep_watch_kill.py"), | ||
| ); | ||
| const skillContent = readPluginAsset(PLUGIN_SKILL_PATH); | ||
|
|
||
| const watchPy = path.join(hooksDir, "mgrep_watch.py"); | ||
| const killPy = path.join(hooksDir, "mgrep_watch_kill.py"); | ||
| writeFileIfChanged(watchPy, watchHook); | ||
| writeFileIfChanged(killPy, killHook); | ||
|
|
||
| const hookConfig: HooksConfig = { | ||
| SessionStart: [ | ||
| { | ||
| matcher: "startup|resume", | ||
| hooks: [ | ||
| { | ||
| type: "command", | ||
| command: `python3 "${watchPy}"`, | ||
| timeout: 10, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| SessionEnd: [ | ||
| { | ||
| hooks: [ | ||
| { | ||
| type: "command", | ||
| command: `python3 "${killPy}"`, | ||
| timeout: 10, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }; | ||
| writeFileIfChanged( | ||
| path.join(skillsDir, "SKILL.md"), | ||
| skillContent.trimStart(), | ||
| ); | ||
|
|
||
| const settings = loadSettings(settingsPath); | ||
| settings.enableHooks = true; | ||
| settings.allowBackgroundProcesses = true; | ||
| settings.hooks = mergeHooks( | ||
| isHooksConfig(settings.hooks) ? settings.hooks : undefined, | ||
| hookConfig, | ||
| ); | ||
| saveSettings(settingsPath, settings as Record<string, unknown>); | ||
|
|
||
| console.log( | ||
| `Installed the mgrep hooks and skill for Factory Droid in ${root}`, | ||
| ); | ||
| } | ||
|
|
||
| async function uninstallPlugin() { | ||
| const root = resolveDroidRoot(); | ||
| const hooksDir = path.join(root, "hooks", "mgrep"); | ||
| const skillsDir = path.join(root, "skills", "mgrep"); | ||
| const settingsPath = path.join(root, "settings.json"); | ||
|
|
||
| if (fs.existsSync(hooksDir)) { | ||
| fs.rmSync(hooksDir, { recursive: true, force: true }); | ||
| console.log("Removed mgrep hooks from Factory Droid"); | ||
| } else { | ||
| console.log("No mgrep hooks found for Factory Droid"); | ||
| } | ||
|
|
||
| if (fs.existsSync(skillsDir)) { | ||
| fs.rmSync(skillsDir, { recursive: true, force: true }); | ||
| console.log("Removed mgrep skill from Factory Droid"); | ||
| } else { | ||
| console.log("No mgrep skill found for Factory Droid"); | ||
| } | ||
|
|
||
| if (fs.existsSync(settingsPath)) { | ||
| try { | ||
| const settings = loadSettings(settingsPath); | ||
| const hooks = isHooksConfig(settings.hooks) ? settings.hooks : undefined; | ||
| if (hooks) { | ||
| for (const event of Object.keys(hooks)) { | ||
| const filtered = hooks[event].filter( | ||
| (entry) => | ||
| entry?.hooks?.[0]?.command !== | ||
| `python3 "${path.join(hooksDir, "mgrep_watch.py")}"` && | ||
| entry?.hooks?.[0]?.command !== | ||
| `python3 "${path.join(hooksDir, "mgrep_watch_kill.py")}"`, | ||
| ); | ||
| if (filtered.length === 0) { | ||
| delete hooks[event]; | ||
| } else { | ||
| hooks[event] = filtered; | ||
| } | ||
| } | ||
| if (Object.keys(hooks).length === 0) { | ||
| delete settings.hooks; | ||
| } | ||
| saveSettings(settingsPath, settings as Record<string, unknown>); | ||
| } | ||
| } catch (error) { | ||
| console.warn( | ||
| `Failed to update Factory Droid settings during uninstall: ${error}`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export const installDroid = new Command("install-droid") | ||
| .description("Install the mgrep hooks and skill for Factory Droid") | ||
| .action(async () => { | ||
| await ensureAuthenticated(); | ||
| await installPlugin(); | ||
| }); | ||
|
|
||
| export const uninstallDroid = new Command("uninstall-droid") | ||
| .description("Uninstall the mgrep hooks and skill for Factory Droid") | ||
| .action(async () => { | ||
| await uninstallPlugin(); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.