diff --git a/package-lock.json b/package-lock.json index 88f1197..87815c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-stars-manager", - "version": "0.1.8", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-stars-manager", - "version": "0.1.8", + "version": "0.2.6", "dependencies": { "date-fns": "^3.3.1", "lucide-react": "^0.344.0", diff --git a/scripts/build-desktop.js b/scripts/build-desktop.js index cf49905..2323565 100644 --- a/scripts/build-desktop.js +++ b/scripts/build-desktop.js @@ -19,8 +19,9 @@ if (!fs.existsSync(electronDir)) { // 3. 创建主进程文件 const mainJs = ` -const { app, BrowserWindow, Menu, shell } = require('electron'); +const { app, BrowserWindow, Menu, shell, ipcMain } = require('electron'); const path = require('path'); +const fs = require('fs'); const isDev = process.env.NODE_ENV === 'development'; let mainWindow; @@ -35,11 +36,12 @@ function createWindow() { nodeIntegration: false, contextIsolation: true, enableRemoteModule: false, - webSecurity: true + webSecurity: true, + preload: path.join(__dirname, 'preload.js'), }, icon: path.join(__dirname, '../dist/icon.svg'), titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', - show: false + show: false, }); // 加载应用 @@ -52,60 +54,6 @@ function createWindow() { mainWindow.once('ready-to-show', () => { mainWindow.show(); - - // 设置应用菜单 - if (process.platform === 'darwin') { - const template = [ - { - label: 'GitHub Stars Manager', - submenu: [ - { role: 'about' }, - { type: 'separator' }, - { role: 'services' }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideothers' }, - { role: 'unhide' }, - { type: 'separator' }, - { role: 'quit' } - ] - }, - { - label: 'Edit', - submenu: [ - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { role: 'selectall' } - ] - }, - { - label: 'View', - submenu: [ - { role: 'reload' }, - { role: 'forceReload' }, - { role: 'toggleDevTools' }, - { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, - { type: 'separator' }, - { role: 'togglefullscreen' } - ] - }, - { - label: 'Window', - submenu: [ - { role: 'minimize' }, - { role: 'close' } - ] - } - ]; - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); - } }); // 处理外部链接 @@ -119,6 +67,47 @@ function createWindow() { }); } +// 获取快照文件路径(固定位置) +function getSnapshotPath() { + const appDataPath = app.getPath('userData'); + return path.join(appDataPath, 'github-stars.snapshot.json'); +} + +// 写快照文件 +ipcMain.handle('write-snapshot', async (event, data) => { + try { + const snapshotPath = getSnapshotPath(); + const dir = path.dirname(snapshotPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + fs.writeFileSync(snapshotPath, content, 'utf8'); + return { ok: true, path: snapshotPath }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + +// 读快照文件 +ipcMain.handle('read-snapshot', async () => { + try { + const snapshotPath = getSnapshotPath(); + if (!fs.existsSync(snapshotPath)) { + return { ok: false, error: 'Snapshot file not found', path: snapshotPath }; + } + const content = fs.readFileSync(snapshotPath, 'utf8'); + return { ok: true, data: JSON.parse(content), path: snapshotPath }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + +// 获取快照路径 +ipcMain.handle('get-snapshot-path', async () => { + return getSnapshotPath(); +}); + app.whenReady().then(createWindow); app.on('window-all-closed', () => { @@ -133,7 +122,6 @@ app.on('activate', () => { } }); -// 安全设置 app.on('web-contents-created', (event, contents) => { contents.on('new-window', (event, navigationUrl) => { event.preventDefault(); @@ -144,22 +132,35 @@ app.on('web-contents-created', (event, contents) => { fs.writeFileSync(path.join(electronDir, 'main.js'), mainJs); -// 4. 创建Electron package.json +// 4. 创建 preload.js(安全桥接) +const preloadJs = ` +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + writeSnapshot: (data) => ipcRenderer.invoke('write-snapshot', data), + readSnapshot: () => ipcRenderer.invoke('read-snapshot'), + getSnapshotPath: () => ipcRenderer.invoke('get-snapshot-path'), +}); +`; + +fs.writeFileSync(path.join(electronDir, 'preload.js'), preloadJs); + +// 5. 创建Electron package.json const electronPackageJson = { name: 'github-stars-manager-desktop', version: '1.0.0', description: 'GitHub Stars Manager Desktop App', main: 'main.js', author: 'GitHub Stars Manager', - license: 'MIT' + license: 'MIT', }; fs.writeFileSync( - path.join(electronDir, 'package.json'), + path.join(electronDir, 'package.json'), JSON.stringify(electronPackageJson, null, 2) ); -// 5. 安装Electron依赖 +// 6. 安装Electron依赖 console.log('📥 安装Electron依赖...'); try { execSync('npm install --save-dev electron electron-builder', { stdio: 'inherit' }); @@ -168,7 +169,7 @@ try { process.exit(1); } -// 6. 构建应用 +// 7. 构建应用 console.log('🔨 构建桌面应用...'); try { execSync('npx electron-builder', { stdio: 'inherit' }); @@ -177,4 +178,4 @@ try { } catch (error) { console.error('构建失败:', error.message); process.exit(1); -} \ No newline at end of file +} diff --git a/skills/githubstars-snapshot/SKILL.md b/skills/githubstars-snapshot/SKILL.md new file mode 100644 index 0000000..a55020a --- /dev/null +++ b/skills/githubstars-snapshot/SKILL.md @@ -0,0 +1,82 @@ +--- +name: githubstars-snapshot +description: > + Query, categorize, and tag your GitHub starred repositories using a local + snapshot maintained by the GithubStarsManager desktop app. Use when: searching + your GitHub stars for specific topics, answering questions about your own + starred repos, organizing or labeling stars, finding repos by name/description/topic/language. + Trigger phrases: "哪些仓库…", "有没有…", "我的stars里", "搜一下我的仓库", any question + about the user's own GitHub starred repositories. +--- + +# GitHub Stars Snapshot + +Query and manage your GitHub starred repositories via a local snapshot file maintained by the desktop app. + +## Snapshot file location + +The GithubStarsManager desktop app writes a snapshot to a fixed path. Pass this path to the tool with `--snapshot`: + +**macOS:** `~/Library/Application Support/github-stars-manager-desktop/github-stars.snapshot.json` +**Linux:** `~/.config/github-stars-manager-desktop/github-stars.snapshot.json` +**Windows:** `%APPDATA%/github-stars-manager-desktop/github-stars.snapshot.json` + +## Tool + +**Script:** `./scripts/githubstars-snapshot-tool.mjs` + +## Commands + +```bash +# Search repositories (returns ranked results by relevance) +node ./scripts/githubstars-snapshot-tool.mjs search "mcp server" --snapshot ~/Library/Application\ Support/github-stars-manager-desktop/github-stars.snapshot.json + +# Set a repository's category +node ./scripts/githubstars-snapshot-tool.mjs category set --repo owner/name --category ai-tools --snapshot + +# Add tags to a repository (comma-separated, duplicates are deduplicated) +node ./scripts/githubstars-snapshot-tool.mjs tags add --repo owner/name --tags mcp,agent --snapshot + +# Preview changes without writing (dry-run) +node ./scripts/githubstars-snapshot-tool.mjs tags add --repo owner/name --tags mcp,agent --snapshot --dry-run +node ./scripts/githubstars-snapshot-tool.mjs category set --repo owner/name --category ai-tools --snapshot --dry-run +``` + +## Workflow + +1. **Find the snapshot file** — the desktop app maintains it at the paths above. If multiple platforms are in use, the snapshot lives in the user home of whichever machine ran the desktop app most recently. +2. **Search first** — always start with `search` to locate the target repository by name, topic, description, or language. +3. **Act if needed** — use `category set` or `tags add` to organize. Prefer `--dry-run` first to preview. +4. **Progressive disclosure** — if a search result's metadata is insufficient, the next step is reading the repository README, not the source code. Only escalate to code inspection when the question genuinely requires it. + +## Snapshot schema + +```json +{ + "version": 1, + "exportedAt": "2026-03-29T00:00:00.000Z", + "repositories": [ + { + "id": 1, + "name": "repo-name", + "full_name": "owner/repo-name", + "description": "What this repo does", + "html_url": "https://github.com/owner/repo-name", + "stargazers_count": 1234, + "language": "TypeScript", + "topics": ["mcp", "ai"], + "ai_summary": "AI-generated description", + "ai_tags": ["server", "agent"], + "custom_tags": ["infra"], + "custom_category": "ai-tools" + } + ], + "categories": [] +} +``` + +## Notes + +- If the snapshot file does not exist yet, the desktop app has not been run or no repositories have been synced. Ask the user to open the app and sync their stars first. +- The `--snapshot` flag is required unless running from the repo root with a default path configured. +- The tool writes changes directly to the snapshot file. Use `--dry-run` to preview without modifying. diff --git a/skills/githubstars-snapshot/scripts/githubstars-snapshot-tool.mjs b/skills/githubstars-snapshot/scripts/githubstars-snapshot-tool.mjs new file mode 100644 index 0000000..89333ed --- /dev/null +++ b/skills/githubstars-snapshot/scripts/githubstars-snapshot-tool.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const normalize = (value) => (value || '').toLowerCase().trim(); + +const repoText = (repo) => [ + repo.name, + repo.full_name, + repo.description, + repo.language, + repo.ai_summary, + ...(repo.topics || []), + ...(repo.ai_tags || []), + ...(repo.custom_tags || []), + ...(repo.ai_platforms || []), + repo.custom_description, + repo.custom_category, +].filter(Boolean).join(' ').toLowerCase(); + +const scoreRepository = (repo, queryWords) => { + const text = repoText(repo); + const name = normalize(repo.name); + const fullName = normalize(repo.full_name); + let score = 0; + for (const word of queryWords) { + if (name === word) score += 10; + if (name.includes(word)) score += 6; + if (fullName.includes(word)) score += 5; + if (text.includes(word)) score += 2; + } + return score; +}; + +const searchRepositories = (repositories, query, limit) => { + const normalizedQuery = normalize(query); + if (!normalizedQuery) { + return repositories.slice(0, limit); + } + + const queryWords = normalizedQuery.split(/\s+/).filter(Boolean); + return repositories + .map((repo) => ({ repo, score: scoreRepository(repo, queryWords) })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || b.repo.stargazers_count - a.repo.stargazers_count) + .slice(0, limit) + .map((item) => item.repo); +}; + +const dedupe = (values) => { + const seen = new Set(); + const result = []; + for (const raw of values) { + const value = raw.trim(); + if (!value) continue; + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(value); + } + return result; +}; + +const printJson = (value) => { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +}; + +const parseArgs = (args) => { + const flags = new Map(); + const positional = []; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg.startsWith('--')) { + const next = args[i + 1]; + if (!next || next.startsWith('--')) { + flags.set(arg, true); + } else { + flags.set(arg, next); + i += 1; + } + } else { + positional.push(arg); + } + } + + return { flags, positional }; +}; + +const getSnapshotPath = (flags) => { + const direct = flags.get('--snapshot'); + if (typeof direct === 'string' && direct.trim()) return path.resolve(direct); + return path.resolve(process.cwd(), 'github-stars.snapshot.json'); +}; + +const loadSnapshot = (snapshotPath) => { + if (!fs.existsSync(snapshotPath)) { + throw new Error(`Snapshot not found: ${snapshotPath}`); + } + return JSON.parse(fs.readFileSync(snapshotPath, 'utf8')); +}; + +const saveSnapshot = (snapshotPath, snapshot) => { + snapshot.exportedAt = new Date().toISOString(); + fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2)); +}; + +const findByRepo = (repositories, fullName) => { + const normalized = normalize(fullName); + const repo = repositories.find((item) => normalize(item.full_name) === normalized); + if (!repo) throw new Error(`Repository not found: ${fullName}`); + return repo; +}; + +const replaceRepository = (repositories, updated) => { + return repositories.map((repo) => (repo.id === updated.id ? updated : repo)); +}; + +const main = () => { + const args = process.argv.slice(2); + const command = args[0]; + + if (!command) { + printJson({ ok: false, error: 'Missing command' }); + process.exit(1); + } + + if (command === 'search') { + const { flags, positional } = parseArgs(args.slice(1)); + const snapshotPath = getSnapshotPath(flags); + const query = positional.join(' ').trim(); + const limit = Number(flags.get('--limit') || 20); + const snapshot = loadSnapshot(snapshotPath); + const repositories = searchRepositories(snapshot.repositories, query, limit); + printJson({ ok: true, query, total: repositories.length, repositories }); + return; + } + + const subcommand = args[1]; + const { flags } = parseArgs(args.slice(2)); + const snapshotPath = getSnapshotPath(flags); + + if (command === 'category' && subcommand === 'set') { + const snapshot = loadSnapshot(snapshotPath); + const category = String(flags.get('--category') || '').trim(); + const repoName = typeof flags.get('--repo') === 'string' ? String(flags.get('--repo')) : ''; + if (!category || !repoName) throw new Error('category set requires --repo and --category'); + const repo = findByRepo(snapshot.repositories, repoName); + const updated = { ...repo, custom_category: category, last_edited: new Date().toISOString() }; + snapshot.repositories = replaceRepository(snapshot.repositories, updated); + if (!flags.has('--dry-run')) saveSnapshot(snapshotPath, snapshot); + printJson({ ok: true, action: 'category.set', dryRun: flags.has('--dry-run'), repository: updated }); + return; + } + + if (command === 'tags' && subcommand === 'add') { + const snapshot = loadSnapshot(snapshotPath); + const tags = String(flags.get('--tags') || '').split(',').map((item) => item.trim()).filter(Boolean); + const repoName = typeof flags.get('--repo') === 'string' ? String(flags.get('--repo')) : ''; + if (!tags.length || !repoName) throw new Error('tags add requires --repo and --tags'); + const repo = findByRepo(snapshot.repositories, repoName); + const updated = { + ...repo, + custom_tags: dedupe([...(repo.custom_tags || []), ...tags]), + last_edited: new Date().toISOString(), + }; + snapshot.repositories = replaceRepository(snapshot.repositories, updated); + if (!flags.has('--dry-run')) saveSnapshot(snapshotPath, snapshot); + printJson({ ok: true, action: 'tags.add', dryRun: flags.has('--dry-run'), repository: updated }); + return; + } + + printJson({ ok: false, error: `Unsupported command: ${command}${subcommand ? ` ${subcommand}` : ''}` }); + process.exit(1); +}; + +try { + main(); +} catch (error) { + printJson({ ok: false, error: error instanceof Error ? error.message : String(error) }); + process.exit(1); +} diff --git a/src/App.tsx b/src/App.tsx index 660521e..f4a18e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { useAutoUpdateCheck } from './components/UpdateChecker'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { backend } from './services/backendAdapter'; import { syncFromBackend, startAutoSync, stopAutoSync } from './services/autoSync'; +import { syncSnapshotToLocalStorage, writeSnapshotToDesktopFile } from './services/snapshotStorage'; function App() { const { @@ -20,6 +21,7 @@ function App() { theme, searchResults, repositories, + customCategories, setSelectedCategory, } = useAppStore(); @@ -35,6 +37,19 @@ function App() { } }, [theme]); + // Keep a stable snapshot for local CLI/skills consumption (localStorage + desktop file) + useEffect(() => { + if (!isAuthenticated) return; + const snapshot = syncSnapshotToLocalStorage(repositories, customCategories); + // Also write to the fixed desktop snapshot file + writeSnapshotToDesktopFile({ + version: 1, + exportedAt: new Date().toISOString(), + repositories, + categories: customCategories, + }).catch(console.error); + }, [isAuthenticated, repositories, customCategories]); + // Initialize backend adapter and auto-sync useEffect(() => { let unsubscribe: (() => void) | null = null; diff --git a/src/core/repositoryMutations.ts b/src/core/repositoryMutations.ts new file mode 100644 index 0000000..2265434 --- /dev/null +++ b/src/core/repositoryMutations.ts @@ -0,0 +1,34 @@ +import { Repository } from '../types'; + +const dedupe = (values: string[]): string[] => { + const seen = new Set(); + const result: string[] = []; + + for (const raw of values) { + const value = raw.trim(); + if (!value) continue; + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(value); + } + + return result; +}; + +export function setRepositoryCategory(repo: Repository, category: string): Repository { + return { + ...repo, + custom_category: category.trim(), + last_edited: new Date().toISOString(), + }; +} + +export function addRepositoryTags(repo: Repository, tags: string[]): Repository { + const merged = dedupe([...(repo.custom_tags || []), ...tags]); + return { + ...repo, + custom_tags: merged, + last_edited: new Date().toISOString(), + }; +} diff --git a/src/core/searchRepositories.ts b/src/core/searchRepositories.ts new file mode 100644 index 0000000..9f25e8a --- /dev/null +++ b/src/core/searchRepositories.ts @@ -0,0 +1,64 @@ +import { Repository } from '../types'; +import { SearchOptions, SearchResult } from './snapshotTypes'; + +const normalize = (value: string | null | undefined): string => (value || '').toLowerCase().trim(); + +const repoText = (repo: Repository): string => { + return [ + repo.name, + repo.full_name, + repo.description, + repo.language, + repo.ai_summary, + ...(repo.topics || []), + ...(repo.ai_tags || []), + ...(repo.custom_tags || []), + ...(repo.ai_platforms || []), + repo.custom_description, + repo.custom_category, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); +}; + +const scoreRepository = (repo: Repository, queryWords: string[]): number => { + const text = repoText(repo); + const name = normalize(repo.name); + const fullName = normalize(repo.full_name); + let score = 0; + + for (const word of queryWords) { + if (name === word) score += 10; + if (name.includes(word)) score += 6; + if (fullName.includes(word)) score += 5; + if (text.includes(word)) score += 2; + } + + return score; +}; + +export function searchRepositories(repositories: Repository[], options: SearchOptions): SearchResult { + const query = normalize(options.query || ''); + const limit = Math.max(1, options.limit || 20); + + if (!query) { + const sliced = repositories.slice(0, limit); + return { repositories: sliced, total: repositories.length, query }; + } + + const queryWords = query.split(/\s+/).filter(Boolean); + + const matched = repositories + .map((repo) => ({ repo, score: scoreRepository(repo, queryWords) })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || b.repo.stargazers_count - a.repo.stargazers_count) + .slice(0, limit) + .map((item) => item.repo); + + return { + repositories: matched, + total: matched.length, + query, + }; +} diff --git a/src/core/snapshotTypes.ts b/src/core/snapshotTypes.ts new file mode 100644 index 0000000..743262f --- /dev/null +++ b/src/core/snapshotTypes.ts @@ -0,0 +1,21 @@ +import { Repository, Category } from '../types'; + +export const SNAPSHOT_VERSION = 1; + +export interface GithubStarsSnapshot { + version: number; + exportedAt: string; + repositories: Repository[]; + categories: Category[]; +} + +export interface SearchOptions { + query?: string; + limit?: number; +} + +export interface SearchResult { + repositories: Repository[]; + total: number; + query: string; +} diff --git a/src/services/snapshotStorage.ts b/src/services/snapshotStorage.ts new file mode 100644 index 0000000..40282cc --- /dev/null +++ b/src/services/snapshotStorage.ts @@ -0,0 +1,65 @@ +import { Category, Repository } from '../types'; +import { GithubStarsSnapshot, SNAPSHOT_VERSION } from '../core/snapshotTypes'; + +export const SNAPSHOT_STORAGE_KEY = 'github-stars-manager:snapshot'; + +export function buildSnapshot(repositories: Repository[], categories: Category[]): GithubStarsSnapshot { + return { + version: SNAPSHOT_VERSION, + exportedAt: new Date().toISOString(), + repositories, + categories, + }; +} + +export function writeSnapshotToLocalStorage(snapshot: GithubStarsSnapshot): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot)); +} + +export function syncSnapshotToLocalStorage(repositories: Repository[], categories: Category[]): void { + writeSnapshotToLocalStorage(buildSnapshot(repositories, categories)); +} + +export function readSnapshotFromLocalStorage(): GithubStarsSnapshot | null { + if (typeof window === 'undefined') return null; + const raw = window.localStorage.getItem(SNAPSHOT_STORAGE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as GithubStarsSnapshot; + } catch { + return null; + } +} + +// Write snapshot to the fixed desktop file path via Electron IPC +export async function writeSnapshotToDesktopFile(snapshot: GithubStarsSnapshot): Promise<{ ok: boolean; path?: string; error?: string }> { + const w = window as Window & { electronAPI?: { writeSnapshot: (data: GithubStarsSnapshot) => Promise<{ ok: boolean; path?: string; error?: string }> } }; + if (!w.electronAPI) { + // Not in Electron, fall back to localStorage + writeSnapshotToLocalStorage(snapshot); + return { ok: true }; + } + return w.electronAPI.writeSnapshot(snapshot); +} + +// Read snapshot from the fixed desktop file path via Electron IPC +export async function readSnapshotFromDesktopFile(): Promise<{ ok: boolean; data?: GithubStarsSnapshot; path?: string; error?: string }> { + const w = window as Window & { electronAPI?: { readSnapshot: () => Promise<{ ok: boolean; data?: GithubStarsSnapshot; path?: string; error?: string }> } }; + if (!w.electronAPI) { + // Not in Electron, fall back to localStorage + const data = readSnapshotFromLocalStorage(); + return data ? { ok: true, data } : { ok: false, error: 'No snapshot found' }; + } + return w.electronAPI.readSnapshot(); +} + +// Get the fixed snapshot file path +export async function getSnapshotPath(): Promise { + const w = window as Window & { electronAPI?: { getSnapshotPath: () => Promise } }; + if (!w.electronAPI) { + // Not in Electron, return localStorage key as fallback indicator + return SNAPSHOT_STORAGE_KEY; + } + return w.electronAPI.getSnapshotPath(); +}