Skip to content

Commit 53b3f4f

Browse files
authored
feat: add Chrome extension to save webpages as skills (#60)
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
1 parent 896eff1 commit 53b3f4f

File tree

17 files changed

+809
-0
lines changed

17 files changed

+809
-0
lines changed

packages/api/src/routes/save.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Hono } from 'hono';
2+
import { ContentExtractor, SkillGenerator, AutoTagger } from '@skillkit/core';
3+
4+
interface SaveRequest {
5+
url?: string;
6+
text?: string;
7+
name?: string;
8+
global?: boolean;
9+
}
10+
11+
const MAX_TEXT_LENGTH = 500_000;
12+
13+
const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1', '0.0.0.0']);
14+
15+
function isAllowedUrl(url: string): boolean {
16+
try {
17+
const parsed = new URL(url);
18+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
19+
20+
const hostname = parsed.hostname.toLowerCase();
21+
const bare = hostname.replace(/^\[|\]$/g, '');
22+
23+
if (BLOCKED_HOSTS.has(hostname) || BLOCKED_HOSTS.has(bare)) return false;
24+
if (bare.startsWith('::ffff:')) return isAllowedUrl(`http://${bare.slice(7)}`);
25+
if (/^127\./.test(bare) || /^0\./.test(bare)) return false;
26+
if (bare.startsWith('10.') || bare.startsWith('192.168.')) return false;
27+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(bare)) return false;
28+
if (bare.startsWith('169.254.')) return false;
29+
if (bare.startsWith('fe80:') || bare.startsWith('fc') || bare.startsWith('fd')) return false;
30+
if (/^(22[4-9]|23\d|24\d|25[0-5])\./.test(bare)) return false;
31+
if (bare.startsWith('ff')) return false;
32+
return true;
33+
} catch {
34+
return false;
35+
}
36+
}
37+
38+
export function saveRoutes() {
39+
const app = new Hono();
40+
const extractor = new ContentExtractor();
41+
const generator = new SkillGenerator();
42+
const tagger = new AutoTagger();
43+
44+
app.post('/save', async (c) => {
45+
let body: SaveRequest;
46+
try {
47+
body = await c.req.json();
48+
} catch {
49+
return c.json({ error: 'Invalid JSON body' }, 400);
50+
}
51+
52+
if (!body.url && !body.text) {
53+
return c.json({ error: 'Either "url" or "text" is required' }, 400);
54+
}
55+
56+
if (body.url && !isAllowedUrl(body.url)) {
57+
return c.json({ error: 'URL must be a public HTTP(S) address' }, 400);
58+
}
59+
60+
if (body.text && body.text.length > MAX_TEXT_LENGTH) {
61+
return c.json({ error: `Text exceeds maximum length of ${MAX_TEXT_LENGTH} characters` }, 400);
62+
}
63+
64+
try {
65+
const content = body.url
66+
? await extractor.extractFromUrl(body.url)
67+
: extractor.extractFromText(body.text!);
68+
69+
const result = generator.generate(content, {
70+
name: body.name,
71+
global: body.global ?? true,
72+
});
73+
74+
return c.json({
75+
name: result.name,
76+
skillPath: result.skillPath,
77+
skillMd: result.skillMd,
78+
tags: tagger.detectTags(content),
79+
});
80+
} catch (err) {
81+
const message = err instanceof Error ? err.message : 'Unknown error';
82+
const isTimeout =
83+
(err instanceof DOMException && err.name === 'TimeoutError') ||
84+
(err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError'));
85+
86+
if (isTimeout) {
87+
return c.json({ error: `Fetch timeout: ${message}` }, 504);
88+
}
89+
90+
return c.json({ error: `Extraction failed: ${message}` }, 422);
91+
}
92+
});
93+
94+
return app;
95+
}

packages/api/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { skillRoutes } from './routes/skills.js';
88
import { trendingRoutes } from './routes/trending.js';
99
import { categoryRoutes } from './routes/categories.js';
1010
import { docsRoutes } from './routes/docs.js';
11+
import { saveRoutes } from './routes/save.js';
1112
import type { ApiSkill, SearchResponse } from './types.js';
1213

1314
export interface ServerOptions {
@@ -37,6 +38,7 @@ export function createApp(options: ServerOptions = {}) {
3738
app.route('/', trendingRoutes(skills));
3839
app.route('/', categoryRoutes(skills));
3940
app.route('/', docsRoutes());
41+
app.route('/', saveRoutes());
4042

4143
return { app, cache };
4244
}

packages/extension/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@skillkit/extension",
3+
"version": "1.16.0",
4+
"private": true,
5+
"description": "Chrome extension to save any webpage as an AI agent skill",
6+
"type": "module",
7+
"scripts": {
8+
"build": "tsup && cp src/manifest.json dist/ && cp src/popup.html dist/ && cp src/popup.css dist/ && cp -r src/icons dist/",
9+
"dev": "tsup --watch",
10+
"typecheck": "tsc --noEmit"
11+
},
12+
"dependencies": {
13+
"turndown": "^7.2.2"
14+
},
15+
"devDependencies": {
16+
"@types/chrome": "^0.0.280",
17+
"@types/turndown": "^5.0.6",
18+
"tsup": "^8.3.5",
19+
"typescript": "^5.7.2"
20+
}
21+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { SaveResponse, ErrorResponse, ExtensionMessage } from './types';
2+
3+
const TECH_KEYWORDS = new Set([
4+
'react', 'vue', 'angular', 'svelte', 'next', 'nuxt', 'node', 'deno', 'bun',
5+
'typescript', 'javascript', 'python', 'rust', 'go', 'java', 'kotlin', 'swift',
6+
'docker', 'kubernetes', 'aws', 'gcp', 'azure', 'terraform', 'ansible',
7+
'graphql', 'rest', 'api', 'sql', 'nosql', 'redis', 'postgres', 'mongodb',
8+
'testing', 'ci', 'cd', 'git', 'webpack', 'vite', 'tailwind', 'css',
9+
'ai', 'ml', 'llm', 'mcp', 'agent', 'prompt', 'rag', 'embedding',
10+
]);
11+
12+
chrome.runtime.onInstalled.addListener(() => {
13+
chrome.contextMenus.create({
14+
id: 'save-page',
15+
title: 'Save page as Skill',
16+
contexts: ['page'],
17+
});
18+
19+
chrome.contextMenus.create({
20+
id: 'save-selection',
21+
title: 'Save selection as Skill',
22+
contexts: ['selection'],
23+
});
24+
});
25+
26+
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
27+
if (!tab?.id) return;
28+
29+
try {
30+
if (info.menuItemId === 'save-page') {
31+
const pageInfo = await chrome.tabs.sendMessage(tab.id, { type: 'GET_PAGE_INFO' });
32+
if (pageInfo?.type === 'PAGE_INFO') {
33+
const { url, title, markdown } = pageInfo.payload;
34+
const result = generateAndDownload({ url, title, content: markdown });
35+
notifyTab(tab.id, result);
36+
}
37+
}
38+
39+
if (info.menuItemId === 'save-selection' && info.selectionText) {
40+
const url = tab.url ?? '';
41+
const result = generateAndDownload({ url, title: '', content: info.selectionText });
42+
notifyTab(tab.id, result);
43+
}
44+
} catch {
45+
notifyTab(tab.id, { error: 'Failed to extract page content' });
46+
}
47+
});
48+
49+
chrome.runtime.onMessage.addListener(
50+
(message: ExtensionMessage, _sender, sendResponse) => {
51+
if (message.type === 'SAVE_PAGE') {
52+
const { url, title, markdown, name } = message.payload;
53+
sendResponse(generateAndDownload({ url, title, content: markdown, name }));
54+
} else if (message.type === 'SAVE_SELECTION') {
55+
const { text, url, name } = message.payload;
56+
sendResponse(generateAndDownload({ url, title: '', content: text, name }));
57+
}
58+
return false;
59+
},
60+
);
61+
62+
interface GenerateInput {
63+
url: string;
64+
title: string;
65+
content: string;
66+
name?: string;
67+
}
68+
69+
function generateAndDownload(input: GenerateInput): SaveResponse | ErrorResponse {
70+
try {
71+
const name = slugify(input.name || input.title || titleFromUrl(input.url));
72+
const tags = detectTags(input.url, input.content);
73+
const description = makeDescription(input.content);
74+
const savedAt = new Date().toISOString();
75+
76+
const yamlTags = tags.length > 0
77+
? `tags:\n${tags.map((t) => ` - ${t}`).join('\n')}\n`
78+
: '';
79+
80+
const skillMd =
81+
`---\n` +
82+
`name: ${name}\n` +
83+
`description: ${yamlEscape(description)}\n` +
84+
yamlTags +
85+
`metadata:\n` +
86+
(input.url ? ` source: ${input.url}\n` : '') +
87+
` savedAt: ${savedAt}\n` +
88+
`---\n\n` +
89+
input.content + '\n';
90+
91+
const filename = `${name}/SKILL.md`;
92+
93+
const blob = new Blob([skillMd], { type: 'text/markdown' });
94+
const blobUrl = URL.createObjectURL(blob);
95+
96+
chrome.downloads.download({
97+
url: blobUrl,
98+
filename: `skillkit-skills/${filename}`,
99+
saveAs: false,
100+
}, () => {
101+
URL.revokeObjectURL(blobUrl);
102+
});
103+
104+
return { name, filename, skillMd, tags };
105+
} catch (err) {
106+
return { error: err instanceof Error ? err.message : 'Generation failed' };
107+
}
108+
}
109+
110+
function slugify(input: string): string {
111+
const slug = input
112+
.toLowerCase()
113+
.replace(/[^a-z0-9]+/g, '-')
114+
.replace(/^-+|-+$/g, '')
115+
.replace(/-{2,}/g, '-');
116+
return slug.slice(0, 64).replace(/-+$/, '') || 'untitled-skill';
117+
}
118+
119+
function titleFromUrl(url: string): string {
120+
try {
121+
const pathname = new URL(url).pathname;
122+
const last = pathname.split('/').filter(Boolean).pop();
123+
return last ?? 'untitled';
124+
} catch {
125+
return 'untitled';
126+
}
127+
}
128+
129+
function makeDescription(content: string): string {
130+
const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? '';
131+
const cleaned = firstLine.replace(/^#+\s*/, '').trim();
132+
if (cleaned.length > 200) return cleaned.slice(0, 197) + '...';
133+
return cleaned || 'Saved skill';
134+
}
135+
136+
function yamlEscape(value: string): string {
137+
const singleLine = value.replace(/\r?\n/g, ' ').trim();
138+
if (/[:#{}[\],&*?|>!%@`]/.test(singleLine) || singleLine.startsWith("'") || singleLine.startsWith('"')) {
139+
return `"${singleLine.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
140+
}
141+
return singleLine;
142+
}
143+
144+
function detectTags(url: string, content: string): string[] {
145+
const text = `${url} ${content}`.toLowerCase();
146+
const found: string[] = [];
147+
for (const kw of TECH_KEYWORDS) {
148+
if (text.includes(kw)) found.push(kw);
149+
if (found.length >= 10) break;
150+
}
151+
return found;
152+
}
153+
154+
function notifyTab(tabId: number, result: SaveResponse | ErrorResponse): void {
155+
const isError = 'error' in result;
156+
chrome.action.setBadgeText({ text: isError ? '!' : '\u2713', tabId });
157+
chrome.action.setBadgeBackgroundColor({ color: isError ? '#ef4444' : '#22c55e', tabId });
158+
setTimeout(() => chrome.action.setBadgeText({ text: '', tabId }), 3000);
159+
}

packages/extension/src/content.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import TurndownService from 'turndown';
2+
import type { ExtensionMessage, PageContent } from './types';
3+
4+
const turndown = new TurndownService({
5+
headingStyle: 'atx',
6+
codeBlockStyle: 'fenced',
7+
});
8+
9+
turndown.remove(['script', 'style', 'nav', 'footer', 'iframe', 'noscript']);
10+
11+
function extractPageContent(): PageContent {
12+
const selection = window.getSelection()?.toString() ?? '';
13+
const description =
14+
document.querySelector('meta[name="description"]')?.getAttribute('content') ?? '';
15+
16+
const article = document.querySelector('article, main, [role="main"]');
17+
const source = article ?? document.body;
18+
const markdown = turndown.turndown(source.innerHTML);
19+
20+
return {
21+
url: window.location.href,
22+
title: document.title || description || window.location.hostname,
23+
markdown,
24+
selection,
25+
description,
26+
};
27+
}
28+
29+
chrome.runtime.onMessage.addListener(
30+
(message: ExtensionMessage, _sender, sendResponse) => {
31+
if (message.type === 'GET_PAGE_INFO') {
32+
const content = extractPageContent();
33+
const response: ExtensionMessage = { type: 'PAGE_INFO', payload: content };
34+
sendResponse(response);
35+
}
36+
return false;
37+
},
38+
);
Lines changed: 8 additions & 0 deletions
Loading
3.22 KB
Loading
1.08 KB
Loading
2.68 KB
Loading
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "SkillKit - Save as Skill",
4+
"version": "1.16.0",
5+
"description": "Save any webpage as an AI agent skill — instantly available across 44 agents",
6+
"permissions": ["contextMenus", "activeTab", "storage", "downloads"],
7+
"background": {
8+
"service_worker": "background.global.js"
9+
},
10+
"action": {
11+
"default_popup": "popup.html",
12+
"default_icon": {
13+
"16": "icons/icon16.png",
14+
"48": "icons/icon48.png",
15+
"128": "icons/icon128.png"
16+
}
17+
},
18+
"content_scripts": [
19+
{
20+
"matches": ["<all_urls>"],
21+
"js": ["content.global.js"]
22+
}
23+
],
24+
"icons": {
25+
"16": "icons/icon16.png",
26+
"48": "icons/icon48.png",
27+
"128": "icons/icon128.png"
28+
}
29+
}

0 commit comments

Comments
 (0)