Skip to content

feat: Chrome extension — Save as Skill#60

Merged
rohitg00 merged 1 commit intomainfrom
feat/chrome-extension-save-as-skill
Feb 10, 2026
Merged

feat: Chrome extension — Save as Skill#60
rohitg00 merged 1 commit intomainfrom
feat/chrome-extension-save-as-skill

Conversation

@rohitg00
Copy link
Owner

@rohitg00 rohitg00 commented Feb 10, 2026

Summary

Fully browser-based Chrome extension — no local server required. Click the extension or right-click any page to save it as a SKILL.md.

  • Content script extracts page content as markdown using Turndown (targets article/main or body; strips scripts/nav/footer)
  • Background service worker generates SKILL.md (YAML frontmatter, auto-slugify, keyword-based tag detection) and downloads via chrome.downloads API
  • Downloads to Downloads/skillkit-skills/{name}/SKILL.md — copy to ~/.skillkit/skills/ to make available to all 44 agents
  • POST /save API endpoint still available for CLI skillkit save users (with SSRF protection)

Architecture

User clicks "Save as Skill" (popup or context menu)
  → Content script extracts page HTML → Turndown → markdown
  → Background generates SKILL.md (frontmatter + tags)
  → chrome.downloads.download() → Downloads/skillkit-skills/{name}/SKILL.md

No localhost API needed. Everything happens in the browser.

Test plan

  • pnpm build — All 13 packages compile (extension bundles Turndown into 23KB IIFE)
  • pnpm test — All 25 test tasks pass
  • Load packages/extension/dist/ as unpacked extension in Chrome
  • Click extension icon → "Save as Skill" → SKILL.md downloaded
  • Right-click page → "Save page as Skill" → works via context menu
  • Select text → right-click → "Save selection as Skill"
  • On chrome:// pages → graceful fallback (no content script)

Summary by CodeRabbit

Release Notes

  • New Features
    • Added save API endpoint supporting URL and text input with content extraction and automatic tag detection for skill generation.
    • Chrome extension now available with context menu options to save web pages or text selections as Skill artifacts, featuring automatic file generation and direct browser downloads.

@vercel
Copy link

vercel bot commented Feb 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
skillkit Ready Ready Preview, Comment Feb 10, 2026 9:46am
skillkit-docs Ready Ready Preview, Comment Feb 10, 2026 9:46am

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

Warning

Rate limit exceeded

@rohitg00 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 16 minutes and 58 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Introduces a new API endpoint for saving web content as Skill artifacts and a Chrome extension that enables users to extract and save page content through context menus and a popup interface. The API validates input, extracts content, and generates Skill markdown with metadata. The extension manages content extraction, Skill generation, and file downloads across background, content, and popup scripts.

Changes

Cohort / File(s) Summary
API Save Route
packages/api/src/routes/save.ts, packages/api/src/server.ts
New POST /save endpoint that validates input (URL or text), extracts content via ContentExtractor, generates Skills with SkillGenerator and AutoTagger, and returns JSON with name, skillPath, skillMd, and tags. Handles errors (bad input, disallowed URLs, text length, timeouts, extraction failures) with appropriate HTTP statuses.
Extension Configuration
packages/extension/package.json, packages/extension/tsconfig.json, packages/extension/tsup.config.ts, packages/extension/src/manifest.json
Build and runtime setup for Chrome extension: npm scripts, TypeScript configuration targeting ES2020 with DOM/Chrome types, tsup bundler config with IIFE output for background/content/popup entry points, and Manifest V3 permissions (contextMenus, downloads, storage, activeTab).
Extension Types
packages/extension/src/types.ts
Defines SaveResponse, ErrorResponse, PageContent, and ExtensionMessage union types for inter-component messaging (SAVE_PAGE, SAVE_SELECTION, GET_PAGE_INFO, PAGE_INFO).
Extension Core Modules
packages/extension/src/background.ts, packages/extension/src/content.ts, packages/extension/src/popup.ts
Background service worker registers context menus, orchestrates content extraction and Skill generation, handles downloads; content script extracts page HTML/text via TurndownService and message listener; popup UI initializes active tab info, sends save messages, and displays results.
Extension UI
packages/extension/src/popup.html, packages/extension/src/popup.css
Popup interface with page info display, optional skill name input, save actions, status indicator, and result section; styled with dark theme, flexbox layout, form controls, and state-specific colors (saving, success, error).

Sequence Diagram

sequenceDiagram
    actor User
    participant Popup as Popup Script
    participant ContentScript as Content Script
    participant Background as Background Script
    participant API as API Server

    User->>Popup: Opens extension popup
    Popup->>ContentScript: GET_PAGE_INFO (request page content)
    ContentScript->>ContentScript: Extract title, URL, markdown, selection
    ContentScript->>Popup: PAGE_INFO (return extracted content)
    Popup->>Popup: Populate title, URL, markdown fields

    User->>Popup: Click "Save as Skill"
    Popup->>Background: SAVE_PAGE (url, title, markdown, name?)
    Background->>API: POST /save (url, title, markdown, name?)
    API->>API: Extract content, generate Skill markdown, detect tags
    API->>Background: { name, skillPath, skillMd, tags }
    Background->>Background: Create blob, compute filename, trigger download
    Background->>Popup: SaveResponse (name, filename, skillMd, tags)
    Popup->>Popup: Display success with download path
    Popup->>User: Show result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 A hop, a skip, saves skills with a click!
Content flows from web to file, so slick,
Extension whispers to server with care,
Markdown blooms as artifacts rare. 🌱✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Chrome extension — Save as Skill' clearly and specifically summarizes the main change: adding a Chrome extension that enables saving web content as Skill artifacts.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/chrome-extension-save-as-skill

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.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

View 7 additional findings in Devin Review.

Open in Devin Review

Comment on lines +13 to +27
function isAllowedUrl(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
const hostname = parsed.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') return false;
if (hostname.startsWith('10.') || hostname.startsWith('192.168.')) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return false;
if (hostname.startsWith('169.254.')) return false;
if (hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) return false;
return true;
} catch {
return false;
}
}

Choose a reason for hiding this comment

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

🔴 SSRF bypass: IPv6-mapped IPv4 addresses (::ffff:127.0.0.1) bypass all private-IP checks

The SSRF check only tests hostname strings against IPv4 patterns (e.g., startsWith('10.'), startsWith('192.168.')) and a few IPv6 prefixes. It does not handle IPv6-mapped IPv4 addresses like ::ffff:127.0.0.1, ::ffff:10.0.0.1, ::ffff:192.168.1.1, or ::ffff:169.254.169.254, all of which bypass the filter and route to the corresponding private IPv4 address.

Root Cause and Impact

When a user submits http://[::ffff:127.0.0.1]/ as the URL, URL.hostname becomes [::ffff:7f00:1]. This doesn't match any of the existing string-based checks:

  • Not equal to 127.0.0.1 or localhost
  • Doesn't start with 10., 192.168., 169.254., etc.
  • Doesn't start with fe80:, fc, or fd

But when Node.js fetch() (called in packages/core/src/save/extractor.ts:65) resolves this address, it connects to 127.0.0.1.

Similarly:

  • http://[::ffff:169.254.169.254]/ → reaches AWS metadata endpoint
  • http://[::ffff:10.0.0.1]/ → reaches internal 10.x network
  • http://[::ffff:192.168.1.1]/ → reaches internal 192.168.x network

Impact: Full SSRF bypass to any private IPv4 address via IPv6-mapped notation, including cloud metadata endpoints.

Prompt for agents
In packages/api/src/routes/save.ts isAllowedUrl function, after stripping brackets from the hostname, add a check to detect IPv6-mapped IPv4 addresses. These have the form ::ffff:x.x.x.x or ::ffff:HHHH:HHHH. Strip the '::ffff:' prefix if present, then check if the remaining part is a dotted IPv4 address and re-run all private IPv4 range checks against it. Also consider using a proper IP parsing library (like 'ipaddr.js') that can normalize all address formats and check range membership, rather than relying on string prefix matching.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 13 additional findings in Devin Review.

Open in Devin Review

if (hostname.startsWith('10.') || hostname.startsWith('192.168.')) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return false;
if (hostname.startsWith('169.254.')) return false;
if (hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) return false;

Choose a reason for hiding this comment

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

🔴 SSRF fc/fd prefix check blocks legitimate public domains (e.g. fcc.gov, fdic.gov)

The check hostname.startsWith('fc') || hostname.startsWith('fd') on line 22 is intended to block IPv6 Unique Local Addresses (fc00::/7), but it operates on the raw hostname string. This incorrectly blocks legitimate public domains like fcc.gov, fdic.gov, fd-example.com, etc.

Root Cause and Impact

The code at packages/api/src/routes/save.ts:22:

if (hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) return false;

This string prefix check is far too broad for regular domain names. Any domain starting with "fc" or "fd" (e.g., fcc.gov, fdic.gov, facebook-dev.example.com) will be rejected as a private address. Meanwhile, the actual IPv6 ULA addresses it's meant to block ([fc00::1]) have brackets and don't match startsWith('fc') anyway (see BUG-0001).

Impact: Users cannot save skills from any URL whose hostname starts with "fc" or "fd", causing false rejections for legitimate public websites.

Prompt for agents
In packages/api/src/routes/save.ts line 22, the IPv6 ULA and link-local checks need to operate on the bare hostname (with brackets stripped) and should not match against regular domain names. Strip brackets from the hostname before checking IPv6 patterns. For example, extract the bare address with hostname.replace(/^\[|\]$/g, '') and only apply the fc/fd/fe80 checks to that bare value when it looks like an IPv6 address (contains a colon). This prevents false positives on domains like fcc.gov while still blocking [fc00::1].
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +59 to +62
const result = generator.generate(content, {
name: body.name,
global: body.global ?? true,
});

Choose a reason for hiding this comment

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

🔴 API /save endpoint writes files to server filesystem via SkillGenerator.generate()

The /save API endpoint calls generator.generate() which internally calls mkdirSync and writeFileSync to write SKILL.md files to the server's disk. Every POST request creates directories and files on the server.

Root Cause and Impact

At packages/api/src/routes/save.ts:59-62:

const result = generator.generate(content, {
  name: body.name,
  global: body.global ?? true,
});

The SkillGenerator.generate() method (packages/core/src/save/skill-generator.ts:46-50) performs filesystem writes:

const outputDir = options.outputDir ?? this.defaultOutputDir(name, options.global);
mkdirSync(outputDir, { recursive: true });
const skillPath = join(outputDir, 'SKILL.md');
writeFileSync(skillPath, skillMd, 'utf-8');

With global: true (the default), this writes to ~/.skillkit/skills/<name>/SKILL.md on the server. Since name comes from user input (after slugification), any unauthenticated caller can fill the server's disk with arbitrary files. The API appears to be intended as a stateless content-generation endpoint that returns JSON, but it has an unintended side effect of persisting files.

Impact: Unauthenticated disk writes on the server; potential disk-filling DoS; the server accumulates files from every request with no cleanup mechanism.

Prompt for agents
In packages/api/src/routes/save.ts, the SkillGenerator.generate() method writes files to the server filesystem which is not appropriate for an API endpoint. Instead, either: (1) refactor SkillGenerator to have a method that only generates the skillMd content without writing to disk, or (2) use ContentExtractor to get the content and then build the SKILL.md string directly in the route handler (similar to how background.ts does it), returning only the JSON response without any filesystem side effects.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link

@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: 4

🤖 Fix all issues with AI agents
In `@packages/api/src/routes/save.ts`:
- Around line 24-28: The private-range prefix checks on the hostname variable
bare are currently applied to raw hostnames and must only run when bare is a
literal IP; update the logic so you first detect whether bare is an IP (use a
canonical IP test such as Node's net.isIP or a robust IP regex) and only then
apply the existing checks (the ::ffff: handling, startsWith('10.'),
startsWith('192.168.'), the /^172\.(1[6-9]|2\d|3[01])\./ regex,
startsWith('169.254.'), startsWith('fe80:'/ 'fc'/ 'fd')). Ensure IPv4-mapped
IPv6 (::ffff:) handling remains correct and skip these prefix checks when bare
is a domain name (instead of blocking domains like fcbarcelona.com); consider
adding a comment about resolving hostnames later if DNS rebinding must be
addressed.

In `@packages/extension/src/background.ts`:
- Around line 69-108: The download callback in generateAndDownload revokes the
blobUrl immediately which can cancel Chrome's read; instead delay revocation (or
wait for chrome.downloads.onChanged to confirm the download has
started/completed) before calling URL.revokeObjectURL(blobUrl). Update the
callback passed to chrome.downloads.download in generateAndDownload to schedule
URL.revokeObjectURL(blobUrl) after a short timeout (e.g. ~500–1000ms) or hook
chrome.downloads.onChanged for the returned downloadId and revoke once the
download state advances, ensuring blobUrl is not revoked prematurely.
- Around line 80-89: The YAML frontmatter construction in the skillMd template
inserts input.url raw into the `source:` field which can produce invalid YAML
for URLs containing ":" or "#" (see variable skillMd and input.url); update that
interpolation to escape or quote the URL (reuse the existing yamlEscape function
or wrap the value in quotes) so the `source:` line becomes a safe YAML scalar,
e.g., call yamlEscape(input.url) or add explicit quoting when building skillMd.

In `@packages/extension/src/popup.ts`:
- Around line 77-101: The save function currently awaits
chrome.runtime.sendMessage which can reject and cause an unhandled promise; wrap
the call in a try/catch inside save to catch any thrown errors, ensure
setButtonsDisabled(false) is executed in both success and failure (use finally
or restore state in catch), and in the catch call setStatus('error',
errorMessage) and hide resultEl (same behavior as when response is missing or
contains error) so the popup doesn't crash; update handling of the local
response variable accordingly to keep the rest of save (setting resultPath,
setStatus('success')) unchanged when a valid response is returned.
🧹 Nitpick comments (6)
packages/extension/package.json (1)

8-9: dev script doesn't copy static assets to dist/.

tsup --watch only rebuilds TS entry points. The manifest, popup HTML/CSS, and icons won't be in dist/ until a full build is run. This means developers must run build first, and any edits to static assets during watch mode won't be reflected.

Consider using tsup's onSuccess hook in tsup.config.ts or a small helper script to copy statics on each rebuild.

packages/extension/src/manifest.json (2)

18-23: Consider programmatic injection instead of <all_urls> content script.

Injecting a content script into every page the user visits adds overhead and broadens the extension's footprint. Since you already have activeTab permission, you could inject the content script on-demand via chrome.scripting.executeScript() in the background worker when the user triggers a save action. This would:

  • Eliminate the per-page injection cost
  • Remove the need for the broad <all_urls> match pattern
  • Require adding the "scripting" permission instead

This is a common Manifest V3 best practice for extensions that only need page access on user interaction.


6-6: Remove the unused storage permission.

The storage permission is declared in the manifest but chrome.storage API is not used anywhere in the codebase. The "storage" references found are for custom application-level storage classes (InboxStorage, SentStorage, etc.), not the Chrome storage API. Remove this permission to keep the permission set minimal.

packages/extension/src/popup.html (2)

5-5: Non-standard viewport meta value.

content="width=360" is non-standard for a viewport meta tag. Chrome extension popups size themselves based on content dimensions, so this meta tag has no practical effect. You can either remove it or use the conventional width=device-width, initial-scale=1.0.


37-40: Consider adding aria-live to the status region.

Adding role="status" and aria-live="polite" to the status div would allow screen readers to announce state changes (saving, success, error) automatically.

♿ Proposed fix
-    <div id="status" class="status" style="display:none">
+    <div id="status" class="status" style="display:none" role="status" aria-live="polite">
packages/extension/src/background.ts (1)

144-152: detectTags substring matching yields false positives for short keywords.

text.includes(kw) matches substrings — e.g., "go" matches "google", "going", "cargo"; "cd" matches "code"; "ai" matches "plain", "maintain", etc. This degrades tag quality.

Use word-boundary matching instead:

Proposed fix
 function detectTags(url: string, content: string): string[] {
   const text = `${url} ${content}`.toLowerCase();
   const found: string[] = [];
   for (const kw of TECH_KEYWORDS) {
-    if (text.includes(kw)) found.push(kw);
+    if (new RegExp(`\\b${kw}\\b`).test(text)) found.push(kw);
     if (found.length >= 10) break;
   }
   return found;
 }

Comment on lines +24 to +28
if (bare.startsWith('::ffff:')) return isAllowedUrl(`http://${bare.slice(7)}`);
if (bare.startsWith('10.') || bare.startsWith('192.168.')) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(bare)) return false;
if (bare.startsWith('169.254.')) return false;
if (bare.startsWith('fe80:') || bare.startsWith('fc') || bare.startsWith('fd')) return false;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Private-range checks match domain names, not just IPs — false positives and SSRF bypass risk.

The startsWith checks for private IP ranges are applied to the raw hostname, which can be a domain name. This causes two problems:

  1. False positives: Domains like fda.gov, fcbarcelona.com, or 10bis.co.il are incorrectly blocked because they match fd, fc, or 10. prefixes.
  2. SSRF bypass: An attacker can register a domain (e.g., evil.com) that resolves to a private IP, bypassing all these checks entirely (DNS rebinding).

Guard the prefix checks behind an IP-format test so they only apply to actual IP addresses:

Proposed fix
+    // Only apply IP-range checks to numeric/IP hostnames
+    const isIPv4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(bare);
+    const isIPv6 = bare.includes(':');
+
-    if (bare.startsWith('10.') || bare.startsWith('192.168.')) return false;
-    if (/^172\.(1[6-9]|2\d|3[01])\./.test(bare)) return false;
-    if (bare.startsWith('169.254.')) return false;
-    if (bare.startsWith('fe80:') || bare.startsWith('fc') || bare.startsWith('fd')) return false;
+    if (isIPv4) {
+      if (bare.startsWith('10.') || bare.startsWith('192.168.')) return false;
+      if (/^172\.(1[6-9]|2\d|3[01])\./.test(bare)) return false;
+      if (bare.startsWith('169.254.')) return false;
+      if (bare.startsWith('0.')) return false;
+    }
+    if (isIPv6) {
+      if (bare.startsWith('fe80:') || bare.startsWith('fc') || bare.startsWith('fd')) return false;
+    }

Note: DNS rebinding (hostname resolving to a private IP after validation) remains an open concern — consider resolving the hostname and validating the resolved IP if the threat model warrants it.

🤖 Prompt for AI Agents
In `@packages/api/src/routes/save.ts` around lines 24 - 28, The private-range
prefix checks on the hostname variable bare are currently applied to raw
hostnames and must only run when bare is a literal IP; update the logic so you
first detect whether bare is an IP (use a canonical IP test such as Node's
net.isIP or a robust IP regex) and only then apply the existing checks (the
::ffff: handling, startsWith('10.'), startsWith('192.168.'), the
/^172\.(1[6-9]|2\d|3[01])\./ regex, startsWith('169.254.'), startsWith('fe80:'/
'fc'/ 'fd')). Ensure IPv4-mapped IPv6 (::ffff:) handling remains correct and
skip these prefix checks when bare is a domain name (instead of blocking domains
like fcbarcelona.com); consider adding a comment about resolving hostnames later
if DNS rebinding must be addressed.

Comment on lines +69 to +108
function generateAndDownload(input: GenerateInput): SaveResponse | ErrorResponse {
try {
const name = slugify(input.name || input.title || titleFromUrl(input.url));
const tags = detectTags(input.url, input.content);
const description = makeDescription(input.content);
const savedAt = new Date().toISOString();

const yamlTags = tags.length > 0
? `tags:\n${tags.map((t) => ` - ${t}`).join('\n')}\n`
: '';

const skillMd =
`---\n` +
`name: ${name}\n` +
`description: ${yamlEscape(description)}\n` +
yamlTags +
`metadata:\n` +
(input.url ? ` source: ${input.url}\n` : '') +
` savedAt: ${savedAt}\n` +
`---\n\n` +
input.content + '\n';

const filename = `${name}/SKILL.md`;

const blob = new Blob([skillMd], { type: 'text/markdown' });
const blobUrl = URL.createObjectURL(blob);

chrome.downloads.download({
url: blobUrl,
filename: `skillkit-skills/${filename}`,
saveAs: false,
}, () => {
URL.revokeObjectURL(blobUrl);
});

return { name, filename, skillMd, tags };
} catch (err) {
return { error: err instanceof Error ? err.message : 'Generation failed' };
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

URL.revokeObjectURL in download callback may fire before Chrome reads the blob.

The chrome.downloads.download callback fires when the download is initiated, not completed. If Chrome hasn't finished reading the blob URL by then, revocation could cause a failed download. Adding a small delay is a common defensive pattern:

Proposed fix
     chrome.downloads.download({
       url: blobUrl,
       filename: `skillkit-skills/${filename}`,
       saveAs: false,
     }, () => {
-      URL.revokeObjectURL(blobUrl);
+      setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function generateAndDownload(input: GenerateInput): SaveResponse | ErrorResponse {
try {
const name = slugify(input.name || input.title || titleFromUrl(input.url));
const tags = detectTags(input.url, input.content);
const description = makeDescription(input.content);
const savedAt = new Date().toISOString();
const yamlTags = tags.length > 0
? `tags:\n${tags.map((t) => ` - ${t}`).join('\n')}\n`
: '';
const skillMd =
`---\n` +
`name: ${name}\n` +
`description: ${yamlEscape(description)}\n` +
yamlTags +
`metadata:\n` +
(input.url ? ` source: ${input.url}\n` : '') +
` savedAt: ${savedAt}\n` +
`---\n\n` +
input.content + '\n';
const filename = `${name}/SKILL.md`;
const blob = new Blob([skillMd], { type: 'text/markdown' });
const blobUrl = URL.createObjectURL(blob);
chrome.downloads.download({
url: blobUrl,
filename: `skillkit-skills/${filename}`,
saveAs: false,
}, () => {
URL.revokeObjectURL(blobUrl);
});
return { name, filename, skillMd, tags };
} catch (err) {
return { error: err instanceof Error ? err.message : 'Generation failed' };
}
}
function generateAndDownload(input: GenerateInput): SaveResponse | ErrorResponse {
try {
const name = slugify(input.name || input.title || titleFromUrl(input.url));
const tags = detectTags(input.url, input.content);
const description = makeDescription(input.content);
const savedAt = new Date().toISOString();
const yamlTags = tags.length > 0
? `tags:\n${tags.map((t) => ` - ${t}`).join('\n')}\n`
: '';
const skillMd =
`---\n` +
`name: ${name}\n` +
`description: ${yamlEscape(description)}\n` +
yamlTags +
`metadata:\n` +
(input.url ? ` source: ${input.url}\n` : '') +
` savedAt: ${savedAt}\n` +
`---\n\n` +
input.content + '\n';
const filename = `${name}/SKILL.md`;
const blob = new Blob([skillMd], { type: 'text/markdown' });
const blobUrl = URL.createObjectURL(blob);
chrome.downloads.download({
url: blobUrl,
filename: `skillkit-skills/${filename}`,
saveAs: false,
}, () => {
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
});
return { name, filename, skillMd, tags };
} catch (err) {
return { error: err instanceof Error ? err.message : 'Generation failed' };
}
}
🤖 Prompt for AI Agents
In `@packages/extension/src/background.ts` around lines 69 - 108, The download
callback in generateAndDownload revokes the blobUrl immediately which can cancel
Chrome's read; instead delay revocation (or wait for chrome.downloads.onChanged
to confirm the download has started/completed) before calling
URL.revokeObjectURL(blobUrl). Update the callback passed to
chrome.downloads.download in generateAndDownload to schedule
URL.revokeObjectURL(blobUrl) after a short timeout (e.g. ~500–1000ms) or hook
chrome.downloads.onChanged for the returned downloadId and revoke once the
download state advances, ensuring blobUrl is not revoked prematurely.

Comment on lines +80 to +89
const skillMd =
`---\n` +
`name: ${name}\n` +
`description: ${yamlEscape(description)}\n` +
yamlTags +
`metadata:\n` +
(input.url ? ` source: ${input.url}\n` : '') +
` savedAt: ${savedAt}\n` +
`---\n\n` +
input.content + '\n';
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Unescaped URL in YAML frontmatter can produce invalid YAML.

The source URL (line 86) is interpolated raw into YAML. URLs commonly contain : and #, both YAML-significant characters. A URL like https://example.com/page#section will break YAML parsers.

Apply yamlEscape (or simply quote the value) for the source field:

Proposed fix
-      (input.url ? `  source: ${input.url}\n` : '') +
+      (input.url ? `  source: ${yamlEscape(input.url)}\n` : '') +
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const skillMd =
`---\n` +
`name: ${name}\n` +
`description: ${yamlEscape(description)}\n` +
yamlTags +
`metadata:\n` +
(input.url ? ` source: ${input.url}\n` : '') +
` savedAt: ${savedAt}\n` +
`---\n\n` +
input.content + '\n';
const skillMd =
`---\n` +
`name: ${name}\n` +
`description: ${yamlEscape(description)}\n` +
yamlTags +
`metadata:\n` +
(input.url ? ` source: ${yamlEscape(input.url)}\n` : '') +
` savedAt: ${savedAt}\n` +
`---\n\n` +
input.content + '\n';
🤖 Prompt for AI Agents
In `@packages/extension/src/background.ts` around lines 80 - 89, The YAML
frontmatter construction in the skillMd template inserts input.url raw into the
`source:` field which can produce invalid YAML for URLs containing ":" or "#"
(see variable skillMd and input.url); update that interpolation to escape or
quote the URL (reuse the existing yamlEscape function or wrap the value in
quotes) so the `source:` line becomes a safe YAML scalar, e.g., call
yamlEscape(input.url) or add explicit quoting when building skillMd.

Comment on lines +77 to +101
async function save(message: ExtensionMessage) {
setStatus('saving', 'Saving...');
setButtonsDisabled(true);

const response: SaveResponse | ErrorResponse | undefined =
await chrome.runtime.sendMessage(message);

setButtonsDisabled(false);

if (!response) {
setStatus('error', 'No response from background script');
resultEl.style.display = 'none';
return;
}

if ('error' in response) {
setStatus('error', response.error);
resultEl.style.display = 'none';
return;
}

setStatus('success', `Saved "${response.name}"`);
resultPath.textContent = `Downloads/skillkit-skills/${response.filename}`;
resultEl.style.display = 'block';
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unhandled rejection if chrome.runtime.sendMessage throws.

If the background service worker fails to respond (e.g., extension update mid-operation), sendMessage can reject. Wrap in try/catch to avoid an unhandled promise rejection crashing the popup.

Proposed fix
 async function save(message: ExtensionMessage) {
   setStatus('saving', 'Saving...');
   setButtonsDisabled(true);
 
-  const response: SaveResponse | ErrorResponse | undefined =
-    await chrome.runtime.sendMessage(message);
+  let response: SaveResponse | ErrorResponse | undefined;
+  try {
+    response = await chrome.runtime.sendMessage(message);
+  } catch {
+    setButtonsDisabled(false);
+    setStatus('error', 'Extension communication failed');
+    resultEl.style.display = 'none';
+    return;
+  }
 
   setButtonsDisabled(false);
🤖 Prompt for AI Agents
In `@packages/extension/src/popup.ts` around lines 77 - 101, The save function
currently awaits chrome.runtime.sendMessage which can reject and cause an
unhandled promise; wrap the call in a try/catch inside save to catch any thrown
errors, ensure setButtonsDisabled(false) is executed in both success and failure
(use finally or restore state in catch), and in the catch call
setStatus('error', errorMessage) and hide resultEl (same behavior as when
response is missing or contains error) so the popup doesn't crash; update
handling of the local response variable accordingly to keep the rest of save
(setting resultPath, setStatus('success')) unchanged when a valid response is
returned.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 14 additional findings in Devin Review.

Open in Devin Review

Comment on lines +144 to +152
function detectTags(url: string, content: string): string[] {
const text = `${url} ${content}`.toLowerCase();
const found: string[] = [];
for (const kw of TECH_KEYWORDS) {
if (text.includes(kw)) found.push(kw);
if (found.length >= 10) break;
}
return found;
}

Choose a reason for hiding this comment

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

🟡 Extension detectTags uses substring matching, producing pervasive false-positive tags

The extension's detectTags function uses text.includes(kw) for substring matching instead of word-boundary matching. Short keywords like go, ai, ml, cd, ci, rest, css, sql, git, and rag will match common English words, causing nearly every page to be tagged incorrectly.

Root Cause and Impact

At packages/extension/src/background.ts:148:

if (text.includes(kw)) found.push(kw);

This matches substrings rather than whole words. For example:

  • "go" matches "google", "algorithm", "category"
  • "ai" matches "email", "maintain", "container"
  • "cd" matches "incdental" or any URL with "cd" in the path
  • "ci" matches "recipe", "special"
  • "rest" matches "restaurant", "forest"

The core library's AutoTagger (packages/core/src/save/tagger.ts:87) correctly avoids this by using word-boundary regex: new RegExp(\\b${keyword}\b`, 'i')`. The extension should use the same approach.

Impact: The first 10 matching keywords fill the tag list with false positives on virtually any page, making the tag metadata unreliable and obscuring the genuinely relevant tags.

Suggested change
function detectTags(url: string, content: string): string[] {
const text = `${url} ${content}`.toLowerCase();
const found: string[] = [];
for (const kw of TECH_KEYWORDS) {
if (text.includes(kw)) found.push(kw);
if (found.length >= 10) break;
}
return found;
}
function detectTags(url: string, content: string): string[] {
const text = `${url} ${content}`.toLowerCase();
const found: string[] = [];
for (const kw of TECH_KEYWORDS) {
const re = new RegExp(`\\b${kw}\\b`);
if (re.test(text)) found.push(kw);
if (found.length >= 10) break;
}
return found;
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Fully browser-based — no local server required. Content script extracts
page content as markdown (Turndown), background generates SKILL.md with
frontmatter and auto-tags, and downloads via chrome.downloads API.

- Manifest V3 with contextMenus, activeTab, downloads permissions
- Content script: HTML-to-markdown via Turndown, extracts article/main
- Background: SKILL.md generation (slugify, YAML frontmatter, tag detection)
- Downloads to skillkit-skills/{name}/SKILL.md in browser Downloads folder
- Monochromatic popup UI matching website design system
- Context menu: "Save page as Skill" / "Save selection as Skill"
- POST /save API endpoint still available for CLI users
@rohitg00 rohitg00 force-pushed the feat/chrome-extension-save-as-skill branch from fc6783b to 34b8e65 Compare February 10, 2026 09:46
@rohitg00 rohitg00 merged commit 53b3f4f into main Feb 10, 2026
9 checks passed
@rohitg00 rohitg00 deleted the feat/chrome-extension-save-as-skill branch February 10, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant