firefox-css-theme 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eason Wong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # Firefox CSS Theme
2
+
3
+ [![npm version](https://img.shields.io/npm/v/firefox-css-theme)](https://www.npmjs.com/package/firefox-css-theme)
4
+ [![Test](https://github.com/easonwong-de/Firefox-CSS-Theme-MCP/actions/workflows/test.yml/badge.svg)](https://github.com/easonwong-de/Firefox-CSS-Theme-MCP/actions/workflows/test.yml)
5
+
6
+ A CLI toolkit for scaffolding, compiling, live-debugging, and watching Firefox UI and CSS themes (`userChrome.css` and `userContent.css`). It also provides a Model Context Protocol (MCP) server for AI-assisted inspection and styling.
7
+
8
+ ## Requirements
9
+
10
+ - Node.js >= 20
11
+ - Firefox Browser
12
+
13
+ ## Getting Started
14
+
15
+ ### Installation
16
+
17
+ Install as a development dependency within your theme repository:
18
+
19
+ ```bash
20
+ npm install --save-dev firefox-css-theme
21
+ ```
22
+
23
+ You can add workflow scripts to your `package.json`:
24
+
25
+ ```json
26
+ {
27
+ "scripts": {
28
+ "create": "firefox-css-theme create",
29
+ "start": "firefox-css-theme start",
30
+ "profiles": "firefox-css-theme profiles",
31
+ "save": "firefox-css-theme save"
32
+ }
33
+ }
34
+ ```
35
+
36
+ > [!NOTE]
37
+ > `userChrome.css` and `userContent.css` must be located at the project root directory by default; otherwise, specify their paths using the `-c, --chrome` and `-u, --content` options.
38
+
39
+ ### Scaffold Theme Files
40
+
41
+ Generate boilerplate `userChrome.css` and `userContent.css` files with Firefox namespace headers:
42
+
43
+ ```bash
44
+ npm run create
45
+ ```
46
+
47
+ Options:
48
+
49
+ - `-c, --chrome <path>`: Custom output path for `userChrome.css`.
50
+ - `-u, --content <path>`: Custom output path for `userContent.css`.
51
+ - `-f, --force`: Overwrite existing files.
52
+
53
+ ### Start Live Development Session
54
+
55
+ Launch Firefox with hot stylesheet reloading:
56
+
57
+ ```bash
58
+ npm run start
59
+ ```
60
+
61
+ Options:
62
+
63
+ - `-p, --profile <name>`: Designate a specific existing Firefox profile by name. If omitted, Firefox runs in an isolated temporary profile that is automatically created and deleted on exit.
64
+ - `-c, --chrome <path>`: Custom path to `userChrome.css`.
65
+ - `-u, --content <path>`: Custom path to `userContent.css`.
66
+ - `--no-watch`: Disable file watching and live reloading.
67
+ - `--headless`: Run Firefox in headless mode.
68
+ - `--nova-ui`: Enable Firefox Nova UI redesign preferences.
69
+ - `--binary <path>`: Path to custom Firefox executable binary.
70
+
71
+ ### Discover Available Profiles
72
+
73
+ List all detected Firefox profiles:
74
+
75
+ ```bash
76
+ npm run profiles
77
+ ```
78
+
79
+ ### Save Theme to Profile
80
+
81
+ Compile and save stylesheets directly into a profile's `chrome/` directory:
82
+
83
+ ```bash
84
+ npm run save -- --profile <name>
85
+ ```
86
+
87
+ Options:
88
+
89
+ - `-p, --profile <name>`: Target Firefox profile name (mandatory if multiple profiles exist).
90
+ - `-c, --chrome <path>`: Custom path to `userChrome.css`.
91
+ - `-u, --content <path>`: Custom path to `userContent.css`.
92
+ - `-f, --force`: Overwrite without confirmation prompt.
93
+
94
+ > [!WARNING]
95
+ > This operation will overwrite existing CSS themes in the profile. A backup is advised.
96
+
97
+ ## CLI Usage
98
+
99
+ You can also run commands on demand without local installation using `npx firefox-css-theme [command] [option]`.
100
+
101
+ ```
102
+ Options:
103
+ -v, --version Show version number
104
+ -h, --help display help for command
105
+
106
+ Commands:
107
+ create [options] Scaffold starter userChrome.css and userContent.css files
108
+ start [options] Launch Firefox with live stylesheet bundling and hot-reloading
109
+ profiles List all detected Firefox profiles
110
+ save [options] Save compiled stylesheets into a Firefox profile's chrome folder
111
+ mcp [options] Start the Model Context Protocol (MCP) server
112
+ help [command] display help for command
113
+ ```
114
+
115
+ ## MCP Server Usage
116
+
117
+ The MCP server enables AI assistants to inspect the Firefox chrome DOM, query computed styles, inject custom CSS, and capture UI screenshots in real time.
118
+
119
+ ![MCP Usage Example - Customising Close Tab Button](./assets/image.jpg)
120
+
121
+ ### Configuration
122
+
123
+ Add the server to your preferred client or development environment:
124
+
125
+ #### Claude Code
126
+
127
+ ```bash
128
+ claude mcp add firefox-css-theme -- npx -y firefox-css-theme mcp --nova-ui
129
+ ```
130
+
131
+ #### Codex
132
+
133
+ ```bash
134
+ codex mcp add firefox-css-theme -- npx -y firefox-css-theme mcp --nova-ui
135
+ ```
136
+
137
+ #### IDE MCP Settings (`mcpServers`)
138
+
139
+ Using `npx`:
140
+
141
+ ```json
142
+ {
143
+ "mcpServers": {
144
+ "firefox-css-theme": {
145
+ "command": "npx",
146
+ "args": ["-y", "firefox-css-theme", "mcp", "--nova-ui"]
147
+ }
148
+ }
149
+ }
150
+ ```
151
+
152
+ Or using an absolute path to a local build:
153
+
154
+ ```json
155
+ {
156
+ "mcpServers": {
157
+ "firefox-css-theme": {
158
+ "command": "node",
159
+ "args": [
160
+ "/absolute/path/to/firefox-css-theme-mcp/dist/cli.js",
161
+ "mcp",
162
+ "--nova-ui"
163
+ ]
164
+ }
165
+ }
166
+ }
167
+ ```
168
+
169
+ ## Available MCP Tools
170
+
171
+ - `launch_browser`: Launches Firefox with chrome debugging capabilities enabled.
172
+ - `close_browser`: Terminates the browser instance.
173
+ - `customize_toolbar`: Activates or controls the Firefox "Customise Toolbar" mode.
174
+ - `get_ui_tree`: Dumps the hierarchical DOM tree of the chrome window.
175
+ - `query_ui_elements`: Queries elements matching a CSS selector in the browser chrome.
176
+ - `get_computed_styles`: Extracts computed CSS property values of a specific UI element.
177
+ - `inject_theme_css`: Injects stylesheets in the live UI (`target: "chrome" | "content"`).
178
+ - `remove_theme_css`: Removes an injected stylesheet by id (`target: "chrome" | "content"`).
179
+ - `list_theme_css`: Lists all currently injected custom stylesheets (`target: "chrome" | "content"`).
180
+ - `take_ui_screenshot`: Captures a screenshot of the browser window or a specific UI component.
181
+ - `execute_chrome_javascript`: Runs privileged JavaScript in the browser chrome window context.
package/dist/cli.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { Command } from "commander";
5
+ import { createCommand } from "./commands/create.js";
6
+ import { startMcpServer } from "./commands/mcp.js";
7
+ import { profilesCommand } from "./commands/profiles.js";
8
+ import { saveCommand } from "./commands/save.js";
9
+ import { startCommand } from "./commands/start.js";
10
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
11
+ const program = new Command();
12
+ program
13
+ .name("firefox-css-theme")
14
+ .description(packageJson.description)
15
+ .version(packageJson.version, "-v, --version", "Show version number");
16
+ program
17
+ .command("create")
18
+ .description("Scaffold starter userChrome.css and userContent.css files")
19
+ .option("-c, --chrome <path>", "Path to userChrome.css")
20
+ .option("-u, --content <path>", "Path to userContent.css")
21
+ .option("-f, --force", "Overwrite existing files without confirmation")
22
+ .action(async (options) => {
23
+ await createCommand({
24
+ chromePath: options.chrome,
25
+ contentPath: options.content,
26
+ force: options.force,
27
+ });
28
+ });
29
+ program
30
+ .command("start")
31
+ .description("Launch Firefox with live stylesheet bundling and hot-reloading")
32
+ .option("-p, --profile <name>", "Designate Firefox profile name")
33
+ .option("-c, --chrome <path>", "Path to userChrome.css (default: ./userChrome.css)")
34
+ .option("-u, --content <path>", "Path to userContent.css (default: ./userContent.css)")
35
+ .option("--no-watch", "Disable file watching in start mode")
36
+ .option("--headless", "Run Firefox in headless mode")
37
+ .option("--nova-ui", "Enable Firefox Nova UI preferences")
38
+ .option("--binary <path>", "Path to custom Firefox executable")
39
+ .action(async (options) => {
40
+ await startCommand({
41
+ binaryPath: options.binary,
42
+ chromePath: options.chrome,
43
+ contentPath: options.content,
44
+ headless: options.headless,
45
+ novaUi: options.novaUi,
46
+ profileName: options.profile,
47
+ watch: options.watch,
48
+ });
49
+ });
50
+ program
51
+ .command("profiles")
52
+ .description("List all detected Firefox profiles")
53
+ .action(async () => {
54
+ await profilesCommand();
55
+ });
56
+ program
57
+ .command("save")
58
+ .description("Save compiled stylesheets into a Firefox profile's chrome folder")
59
+ .option("-p, --profile <name>", "Target Firefox profile name")
60
+ .option("-c, --chrome <path>", "Path to userChrome.css")
61
+ .option("-u, --content <path>", "Path to userContent.css")
62
+ .option("-f, --force", "Overwrite existing files without confirmation")
63
+ .action(async (options) => {
64
+ await saveCommand({
65
+ chromePath: options.chrome,
66
+ contentPath: options.content,
67
+ force: options.force,
68
+ profileName: options.profile,
69
+ });
70
+ });
71
+ program
72
+ .command("mcp")
73
+ .description("Start the Model Context Protocol (MCP) server")
74
+ .option("--nova-ui", "Enable Firefox Nova UI preferences")
75
+ .option("--headless", "Run Firefox in headless mode")
76
+ .option("--binary <path>", "Path to custom Firefox executable")
77
+ .option("-p, --profile <name>", "Designate Firefox profile name")
78
+ .allowUnknownOption()
79
+ .action(async (options) => {
80
+ await startMcpServer({
81
+ binaryPath: options.binary,
82
+ headless: options.headless,
83
+ novaUi: options.novaUi,
84
+ profileName: options.profile,
85
+ });
86
+ });
87
+ async function main() {
88
+ const scriptBinaryName = path.basename(process.argv[1] || "");
89
+ if (scriptBinaryName === "firefox-css-theme-mcp" ||
90
+ scriptBinaryName === "firefox-css-theme-mcp.js") {
91
+ const isNovaUi = process.argv.includes("--nova-ui");
92
+ const isHeadless = process.argv.includes("--headless");
93
+ await startMcpServer({ headless: isHeadless, novaUi: isNovaUi });
94
+ return;
95
+ }
96
+ await program.parseAsync(process.argv);
97
+ }
98
+ main().catch((error) => {
99
+ console.error(error instanceof Error ? error.message : error);
100
+ process.exit(1);
101
+ });
@@ -0,0 +1,34 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ const DEFAULT_CHROME_CSS = `/* Firefox userChrome.css */
4
+ @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
5
+ @namespace html url("http://www.w3.org/1999/xhtml");
6
+
7
+ `;
8
+ const DEFAULT_CONTENT_CSS = `/* Firefox userContent.css */
9
+ @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
10
+ @namespace html url("http://www.w3.org/1999/xhtml");
11
+
12
+ `;
13
+ /**
14
+ * Scaffolds starter userChrome.css and userContent.css boilerplate files with
15
+ * namespace headers.
16
+ */
17
+ export async function createCommand(options = {}) {
18
+ const chromePath = path.resolve(process.cwd(), options.chromePath || "userChrome.css");
19
+ const contentPath = path.resolve(process.cwd(), options.contentPath || "userContent.css");
20
+ if (existsSync(chromePath) && !options.force) {
21
+ console.warn(`\x1b[33m[skip]\x1b[0m ${chromePath} already exists. Use --force to overwrite.`);
22
+ }
23
+ else {
24
+ writeFileSync(chromePath, DEFAULT_CHROME_CSS, "utf8");
25
+ console.log(`\x1b[32m[created]\x1b[0m ${chromePath}`);
26
+ }
27
+ if (existsSync(contentPath) && !options.force) {
28
+ console.warn(`\x1b[33m[skip]\x1b[0m ${contentPath} already exists. Use --force to overwrite.`);
29
+ }
30
+ else {
31
+ writeFileSync(contentPath, DEFAULT_CONTENT_CSS, "utf8");
32
+ console.log(`\x1b[32m[created]\x1b[0m ${contentPath}`);
33
+ }
34
+ }
@@ -0,0 +1,219 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { firefoxManager } from "../firefox.js";
6
+ import { compileCssString } from "../processor.js";
7
+ /** Creates and starts the Model Context Protocol (MCP) server over standard I/O. */
8
+ export async function startMcpServer(options = {}) {
9
+ const packageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
10
+ const server = new McpServer({
11
+ name: packageJson.name,
12
+ version: packageJson.version,
13
+ });
14
+ server.registerTool("launch_browser", {
15
+ description: "Launch a Firefox instance with chrome debugging capabilities enabled.",
16
+ inputSchema: {
17
+ binaryPath: z
18
+ .string()
19
+ .optional()
20
+ .describe("Optional path to Firefox executable binary."),
21
+ profileName: z
22
+ .string()
23
+ .optional()
24
+ .describe("Optional name of existing Firefox profile. If omitted, uses an isolated temporary profile."),
25
+ headless: z
26
+ .boolean()
27
+ .optional()
28
+ .describe("Optional flag to run Firefox in headless mode."),
29
+ novaUi: z
30
+ .boolean()
31
+ .optional()
32
+ .describe("Optional flag to enable Firefox Nova UI preferences."),
33
+ },
34
+ }, async (parameters) => {
35
+ const profileName = parameters.profileName ?? options.profileName;
36
+ await firefoxManager.initialiseBrowser(parameters.binaryPath ?? options.binaryPath, profileName, parameters.headless ?? options.headless, parameters.novaUi ?? options.novaUi);
37
+ return {
38
+ content: [
39
+ {
40
+ type: "text",
41
+ text: "Firefox launched successfully in chrome context.",
42
+ },
43
+ ],
44
+ };
45
+ });
46
+ server.registerTool("close_browser", { description: "Close the running Firefox instance." }, async () => {
47
+ await firefoxManager.terminateBrowser();
48
+ return {
49
+ content: [
50
+ { type: "text", text: "Firefox closed successfully." },
51
+ ],
52
+ };
53
+ });
54
+ server.registerTool("customize_toolbar", {
55
+ description: "Activate or manage the Firefox Customise Toolbar mode in the browser chrome.",
56
+ inputSchema: {
57
+ action: z
58
+ .enum(["enter", "exit"])
59
+ .default("enter")
60
+ .describe("Action to perform on customise toolbar mode ('enter' or 'exit'). Defaults to 'enter'."),
61
+ },
62
+ }, async (parameters) => {
63
+ const result = await firefoxManager.customiseToolbar(parameters.action);
64
+ return {
65
+ content: [
66
+ { type: "text", text: JSON.stringify(result, null, 2) },
67
+ ],
68
+ };
69
+ });
70
+ server.registerTool("get_ui_tree", {
71
+ description: "Retrieve the hierarchical DOM tree of the Firefox chrome user interface window.",
72
+ inputSchema: {
73
+ rootSelector: z
74
+ .string()
75
+ .default("window")
76
+ .describe("CSS selector for root element."),
77
+ maxDepth: z
78
+ .number()
79
+ .default(3)
80
+ .describe("Maximum recursive depth to traverse."),
81
+ },
82
+ }, async (parameters) => {
83
+ const treeData = await firefoxManager.getUiTree(parameters.rootSelector, parameters.maxDepth);
84
+ return {
85
+ content: [
86
+ { type: "text", text: JSON.stringify(treeData, null, 2) },
87
+ ],
88
+ };
89
+ });
90
+ server.registerTool("query_ui_elements", {
91
+ description: "Find elements in the Firefox UI chrome document matching a CSS selector.",
92
+ inputSchema: {
93
+ selector: z
94
+ .string()
95
+ .describe("CSS selector to query in chrome document (e.g. '#nav-bar', '#TabsToolbar')."),
96
+ },
97
+ }, async (parameters) => {
98
+ const elements = await firefoxManager.queryElements(parameters.selector);
99
+ return {
100
+ content: [
101
+ { type: "text", text: JSON.stringify(elements, null, 2) },
102
+ ],
103
+ };
104
+ });
105
+ server.registerTool("get_computed_styles", {
106
+ description: "Extract computed CSS properties for a Firefox UI chrome element.",
107
+ inputSchema: {
108
+ selector: z
109
+ .string()
110
+ .describe("CSS selector of the UI element (e.g. '#urlbar-background')."),
111
+ properties: z
112
+ .array(z.string())
113
+ .optional()
114
+ .describe("Optional list of specific CSS property names."),
115
+ },
116
+ }, async (parameters) => {
117
+ const styles = await firefoxManager.getComputedStyles(parameters.selector, parameters.properties);
118
+ return {
119
+ content: [
120
+ { type: "text", text: JSON.stringify(styles, null, 2) },
121
+ ],
122
+ };
123
+ });
124
+ server.registerTool("inject_theme_css", {
125
+ description: "Inject or replace a live stylesheet in the Firefox window.",
126
+ inputSchema: {
127
+ css: z.string().describe("CSS rules to inject."),
128
+ id: z
129
+ .string()
130
+ .default("mcp-injected-style")
131
+ .describe("Identifier for the style."),
132
+ target: z
133
+ .enum(["chrome", "content"])
134
+ .describe("Target scope: chrome for browser UI, or content for web pages."),
135
+ },
136
+ }, async (parameters) => {
137
+ const compiledCss = await compileCssString(parameters.css);
138
+ const result = parameters.target === "content"
139
+ ? await firefoxManager.injectContentStyle(compiledCss.css, parameters.id)
140
+ : await firefoxManager.injectChromeStyle(compiledCss.css, parameters.id);
141
+ return {
142
+ content: [
143
+ { type: "text", text: JSON.stringify(result, null, 2) },
144
+ ],
145
+ };
146
+ });
147
+ server.registerTool("remove_theme_css", {
148
+ description: "Remove an injected stylesheet from the Firefox window.",
149
+ inputSchema: {
150
+ id: z.string().describe("Identifier of the style to remove."),
151
+ target: z
152
+ .enum(["chrome", "content"])
153
+ .describe("Target scope of the style to remove: chrome or content."),
154
+ },
155
+ }, async (parameters) => {
156
+ const result = parameters.target === "content"
157
+ ? await firefoxManager.removeContentStyle(parameters.id)
158
+ : await firefoxManager.removeChromeStyle(parameters.id);
159
+ return {
160
+ content: [
161
+ { type: "text", text: JSON.stringify(result, null, 2) },
162
+ ],
163
+ };
164
+ });
165
+ server.registerTool("list_theme_css", {
166
+ description: "List all custom stylesheets currently injected in the Firefox window.",
167
+ inputSchema: {
168
+ target: z
169
+ .enum(["chrome", "content"])
170
+ .describe("Target scope to list: chrome or content."),
171
+ },
172
+ }, async (parameters) => {
173
+ const result = parameters.target === "content"
174
+ ? await firefoxManager.listContentStyles()
175
+ : await firefoxManager.listChromeStyles();
176
+ return {
177
+ content: [
178
+ { type: "text", text: JSON.stringify(result, null, 2) },
179
+ ],
180
+ };
181
+ });
182
+ server.registerTool("take_ui_screenshot", {
183
+ description: "Capture a PNG screenshot of the Firefox browser chrome window or a specific UI component.",
184
+ inputSchema: {
185
+ selector: z
186
+ .string()
187
+ .optional()
188
+ .describe("Optional CSS selector to screenshot a specific element."),
189
+ },
190
+ }, async (parameters) => {
191
+ const screenshot = await firefoxManager.getScreenshot(parameters.selector);
192
+ return {
193
+ content: [
194
+ {
195
+ type: "image",
196
+ data: screenshot.base64Image,
197
+ mimeType: "image/png",
198
+ },
199
+ ],
200
+ };
201
+ });
202
+ server.registerTool("execute_chrome_javascript", {
203
+ description: "Execute privileged JavaScript code in the Firefox chrome window context.",
204
+ inputSchema: {
205
+ script: z
206
+ .string()
207
+ .describe("JavaScript code string to evaluate in chrome context."),
208
+ },
209
+ }, async (parameters) => {
210
+ const result = await firefoxManager.executeChromeScript(parameters.script);
211
+ return {
212
+ content: [
213
+ { type: "text", text: JSON.stringify(result, null, 2) },
214
+ ],
215
+ };
216
+ });
217
+ const transport = new StdioServerTransport();
218
+ await server.connect(transport);
219
+ }
@@ -0,0 +1,17 @@
1
+ import { listFirefoxProfiles } from "../profiles.js";
2
+ /** Executes the 'profiles' CLI command to list all detected Firefox profiles. */
3
+ export async function profilesCommand() {
4
+ const profiles = listFirefoxProfiles();
5
+ if (profiles.length === 0) {
6
+ console.log("No Firefox profiles detected in default configuration directory.");
7
+ return;
8
+ }
9
+ console.log("\nDetected Firefox Profiles:\n");
10
+ for (const profile of profiles) {
11
+ const defaultBadge = profile.isDefault
12
+ ? " \x1b[32m(default)\x1b[0m"
13
+ : "";
14
+ console.log(` \x1b[1m${profile.name}\x1b[0m${defaultBadge}`);
15
+ console.log(` \x1b[90m${profile.path}\x1b[0m\n`);
16
+ }
17
+ }
@@ -0,0 +1,91 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline/promises";
4
+ import { compileCssString } from "../processor.js";
5
+ import { listFirefoxProfiles, resolveProfileDirectoryByName, } from "../profiles.js";
6
+ /**
7
+ * Saves and compiles theme stylesheets directly into the target Firefox
8
+ * profile's chrome folder.
9
+ */
10
+ export async function saveCommand(options = {}) {
11
+ let profileName = options.profileName;
12
+ if (!profileName) {
13
+ const availableProfiles = listFirefoxProfiles();
14
+ if (availableProfiles.length === 0) {
15
+ throw new Error("No Firefox profile detected.");
16
+ }
17
+ else if (availableProfiles.length === 1) {
18
+ profileName = availableProfiles[0].name;
19
+ }
20
+ else {
21
+ const profileNames = availableProfiles
22
+ .map((profile) => profile.name)
23
+ .join(", ");
24
+ throw new Error(`Multiple Firefox profiles detected (${profileNames}). Specify target profile with -p, --profile <name>.`);
25
+ }
26
+ }
27
+ const profileDir = resolveProfileDirectoryByName(profileName);
28
+ const chromeDir = path.join(profileDir, "chrome");
29
+ if (!existsSync(chromeDir)) {
30
+ mkdirSync(chromeDir, { recursive: true });
31
+ }
32
+ const srcChromePath = options.chromePath
33
+ ? path.resolve(process.cwd(), options.chromePath)
34
+ : existsSync(path.resolve(process.cwd(), "userChrome.css"))
35
+ ? path.resolve(process.cwd(), "userChrome.css")
36
+ : undefined;
37
+ const srcContentPath = options.contentPath
38
+ ? path.resolve(process.cwd(), options.contentPath)
39
+ : existsSync(path.resolve(process.cwd(), "userContent.css"))
40
+ ? path.resolve(process.cwd(), "userContent.css")
41
+ : undefined;
42
+ if (!srcChromePath && !srcContentPath) {
43
+ console.warn("No stylesheet files found to save.");
44
+ return;
45
+ }
46
+ const destChromePath = path.join(chromeDir, "userChrome.css");
47
+ const destContentPath = path.join(chromeDir, "userContent.css");
48
+ const requiresConfirmation = !options.force &&
49
+ ((srcChromePath && existsSync(destChromePath)) ||
50
+ (srcContentPath && existsSync(destContentPath)));
51
+ if (requiresConfirmation) {
52
+ const readlineInterface = readline.createInterface({
53
+ input: process.stdin,
54
+ output: process.stdout,
55
+ });
56
+ const userResponse = await readlineInterface.question(`\x1b[33m[warning]\x1b[0m Existing theme files will be overwritten in profile "${profileName}". Proceed? (y/N) `);
57
+ readlineInterface.close();
58
+ if (userResponse.trim().toLowerCase() !== "y") {
59
+ console.log("Save operation cancelled.");
60
+ return;
61
+ }
62
+ }
63
+ if (srcChromePath) {
64
+ const sourceContent = readFileSync(srcChromePath, "utf8");
65
+ let mergedCss = sourceContent;
66
+ if (existsSync(destChromePath)) {
67
+ const existingDestinationContent = readFileSync(destChromePath, "utf8");
68
+ if (!existingDestinationContent.includes(sourceContent.trim()) &&
69
+ !sourceContent.includes(existingDestinationContent.trim())) {
70
+ mergedCss = `${existingDestinationContent}\n\n${sourceContent}`;
71
+ }
72
+ }
73
+ const compiledBundle = await compileCssString(mergedCss, srcChromePath);
74
+ writeFileSync(destChromePath, compiledBundle.css, "utf8");
75
+ console.log(`\x1b[32m[saved]\x1b[0m userChrome.css -> ${destChromePath}`);
76
+ }
77
+ if (srcContentPath) {
78
+ const sourceContent = readFileSync(srcContentPath, "utf8");
79
+ let mergedCss = sourceContent;
80
+ if (existsSync(destContentPath)) {
81
+ const existingDestinationContent = readFileSync(destContentPath, "utf8");
82
+ if (!existingDestinationContent.includes(sourceContent.trim()) &&
83
+ !sourceContent.includes(existingDestinationContent.trim())) {
84
+ mergedCss = `${existingDestinationContent}\n\n${sourceContent}`;
85
+ }
86
+ }
87
+ const compiledBundle = await compileCssString(mergedCss, srcContentPath);
88
+ writeFileSync(destContentPath, compiledBundle.css, "utf8");
89
+ console.log(`\x1b[32m[saved]\x1b[0m userContent.css -> ${destContentPath}`);
90
+ }
91
+ }