feat: add AI tools category, universal npm/custom installs, and update UI with app notes ref #40

This commit is contained in:
N1C4T
2026-03-26 15:14:25 +04:00
parent 1f85b14445
commit d2094f4ebc
13 changed files with 651 additions and 323 deletions

View File

@@ -19,6 +19,7 @@
* [Flatpak](#flatpak)
* [Snap](#snap)
* [Homebrew](#homebrew)
* [Universal Targets](#universal-targets-npm--script)
* [Icon System](#5-icon-system)
* [Valid Categories](#6-valid-categories)
4. [Adding Distributions](#adding-distributions)
@@ -140,12 +141,18 @@ All applications are defined in category-specific JSON files within [`src/lib/ap
"arch": "exact-package-name", // pacman OR AUR package name
"flatpak": "com.vendor.AppId", // FULL Flatpak App ID (reverse DNS)
"snap": "snap-name", // Add --classic if needed
"homebrew": "formula-name" // Formula (CLI) or '--cask name' (GUI)
"homebrew": "formula-name", // Formula (CLI) or '--cask name' (GUI)
"npm": "@scope/package-name", // Global npm install (universal fallback)
"script": "curl -fsSL ... | bash" // Custom install script (universal fallback)
},
"note": "Context for universal targets", // Shown on hover for universal-fallback apps
"unavailableReason": "Markdown install instructions"
}
```
> [!NOTE]
> **Priority system**: Native distro targets (e.g., `arch`, `ubuntu`) always take precedence over universal targets (`npm`, `script`). If an app has both `arch: "ollama"` and `script: "curl ..."`, the script is only used when the user selects a distro where no native package exists.
### 3. Unavailable Reason Guidelines
This field renders Markdown when a target is missing. It serves as the manual fallback instruction.
@@ -217,6 +224,64 @@ Homebrew (macOS/Linux) has two package types. Check [formulae.brew.sh](https://f
* Run `brew search <name>` locally to confirm type.
* We skip `--cask` targets on Linux installs automatically.
#### Universal Targets (npm & script)
Universal targets provide cross-distro installation via package managers or custom scripts. They serve as **fallbacks** — only used when no native distro target exists for the selected distribution.
* **`npm`**: Install via `npm install -g`. Requires Node.js runtime on the system.
* **`script`**: Raw shell command (typically a `curl | bash` installer). Runs directly.
> [!IMPORTANT]
> **Native targets always take priority.** If an app defines `arch: "ollama"` alongside `script: "curl ..."`, the script target is completely ignored when Arch is selected. The fallback only activates for distros without a native package.
**When to use each:**
| Target | Use Case | Example |
| :--- | :--- | :--- |
| `npm` | CLI tools distributed via npmjs.com | `"npm": "@google/gemini-cli"` |
| `script` | Apps with official install scripts | `"script": "curl -fsSL https://ollama.com/install.sh \| sh"` |
**Real examples from the codebase:**
```json
// Ollama: native on arch/fedora/nix/homebrew, falls back to script on ubuntu/debian
{
"id": "ollama",
"targets": {
"fedora": "ollama",
"arch": "ollama",
"nix": "ollama",
"homebrew": "ollama",
"script": "curl -fsSL https://ollama.com/install.sh | sh"
},
"note": "Falls back to official installer (ollama.com/install.sh) on distros without a native package."
}
// Gemini CLI: npm-only (plus homebrew)
{
"id": "gemini-cli",
"targets": {
"npm": "@google/gemini-cli",
"homebrew": "gemini-cli"
},
"note": "Requires Node.js runtime. Installed globally via npm where native packages are unavailable."
}
```
**The `note` field:**
* Used to explain the universal target behavior to users (shown on hover).
* **Required** when using `npm` or `script` targets.
* Keep concise and professional. Examples:
* ✅ `"Falls back to official installer (ollama.com/install.sh) on distros without a native package."`
* ✅ `"Requires Node.js runtime. Installed globally via npm where native packages are unavailable."`
* ❌ `"Installed globally via npm."` *(too terse, doesn't explain when or why)*
**Script safety rules:**
1. Only use **official** installer scripts from the app's own domain.
2. Always use `curl -fsSL` flags (fail silently on errors, follow redirects, show errors).
3. Never combine multiple pipes or add `sudo` — the install script handles privileges itself.
4. Verify the script URL is stable and maintained by the upstream project.
### 5. Icon System
Every app needs an icon! Our JSON format makes it super simple to add icons using [Iconify](https://iconify.design/).
@@ -259,7 +324,7 @@ Just change the `"type"` to `"url"`:
Use **exactly** one of these:
* Web Browsers • Communication • Media • Creative • Gaming • Office
* Dev: Languages • Dev: Editors • Dev: Tools
* Terminal • CLI Tools • VPN & Network • Security • File Sharing • System
* Terminal • CLI Tools • AI Tools • VPN & Network • Security • File Sharing • System
---
@@ -316,6 +381,8 @@ Create a new file `src/lib/scripts/<distroId>.ts`. This file must export a funct
- [ ] Snap `--classic` flag verification.
- [ ] Nix unfree packages added to JSON.
- [ ] Homebrew Casks prefixed correctly.
- [ ] Universal targets: `npm`/`script` only used as fallbacks, `note` field provided.
- [ ] Script URLs verified as official, stable endpoints.
- [ ] `npm run lint` & `npm run test` passed.
---
@@ -345,6 +412,9 @@ Brief description of changes.
## Testing
- [ ] `npm run dev` working
- [ ] `npm run build` passed
- [ ] `npm run test` passed
- [ ] `npm run lint` passed
## Screenshots (if applicable)

View File

@@ -21,6 +21,7 @@ const COLOR_MAP: Record<string, string> = {
'green': '#22c55e',
'teal': '#14b8a6',
'gray': '#6b7280',
'fuchsia': '#d946ef',
};
interface AppItemProps {
@@ -54,10 +55,22 @@ export const AppItem = memo(function AppItem({
}: AppItemProps) {
const getUnavailableText = () => {
const distroName = distros.find(d => d.id === selectedDistro)?.name || '';
return app.unavailableReason || `Not available in ${distroName} repos`;
let msg = '';
if (!isAvailable) {
msg = app.unavailableReason || `Not available in ${distroName} repos`;
}
if (app.note) {
msg = msg ? `${msg}${app.note}` : app.note;
}
return msg;
};
const isAur = selectedDistro === 'arch' && app.targets?.arch && isAurPackage(app.targets.arch);
const universalTarget = !app.targets?.[selectedDistro] ? (
app.targets?.npm ? 'npm' :
app.targets?.script ? 'script' : null
) : null;
const hexColor = COLOR_MAP[color] || COLOR_MAP['gray'];
const checkboxColor = isAur ? '#1793d1' : hexColor;
@@ -111,7 +124,8 @@ export const AppItem = memo(function AppItem({
}}
onMouseEnter={(e) => {
e.stopPropagation();
onTooltipEnter(app.description, e);
const tooltipText = app.note ? `${app.description}${app.note}` : app.description;
onTooltipEnter(tooltipText, e);
}}
onMouseLeave={(e) => {
e.stopPropagation();
@@ -142,15 +156,31 @@ export const AppItem = memo(function AppItem({
<path d="M23 12l-2.44-2.79.34-3.69-3.61-.82-1.89-3.2L12 2.96 8.6 1.5 6.71 4.69 3.1 5.5l.34 3.7L1 12l2.44 2.79-.34 3.7 3.61.82 1.89 3.2 3.4-1.47 3.4 1.46 1.89-3.19 3.61-.82-.34-3.69L23 12m-12.91 4.72l-3.8-3.81 1.48-1.48 2.32 2.33 5.85-5.87 1.48 1.48-7.33 7.35z" />
</svg>
)}
{universalTarget && (
<span
className="ml-1.5 px-1.5 py-[1px] text-[10px] font-bold uppercase rounded-sm border opacity-80"
style={{ color: hexColor, borderColor: hexColor }}
onMouseEnter={(e) => {
e.stopPropagation();
onTooltipEnter(`Installed via ${universalTarget}. Requires correct runtime.`, e);
}}
onMouseLeave={(e) => {
e.stopPropagation();
onTooltipLeave();
}}
>
{universalTarget}
</span>
)}
</div>
{!isAvailable && (
{(!isAvailable || universalTarget) && (
<div
className="relative group flex-shrink-0 cursor-help"
onMouseEnter={(e) => { e.stopPropagation(); onTooltipEnter(getUnavailableText(), e); }}
onMouseLeave={(e) => { e.stopPropagation(); onTooltipLeave(); }}
>
<svg
className="w-[18px] h-[18px] text-[var(--text-muted)] transition-[color,transform] duration-300 hover:rotate-[360deg] hover:scale-110"
className={`w-[18px] h-[18px] transition-[color,transform] duration-300 hover:rotate-[360deg] hover:scale-110 ${universalTarget ? 'text-amber-600/40 hover:text-amber-500/60' : 'text-[var(--text-muted)]'}`}
style={{ color: isFocused ? hexColor : undefined }}
viewBox="0 0 24 24"
fill="currentColor"
@@ -161,5 +191,6 @@ export const AppItem = memo(function AppItem({
</div>
)}
</div>
);
});

View File

@@ -3,7 +3,7 @@
import {
ChevronRight, Globe, MessageCircle, Code2, FileCode, Wrench,
Terminal, Command, Play, Palette, Gamepad2, Briefcase,
Network, Lock, Share2, Cpu, type LucideIcon
Network, Lock, Share2, Cpu, Sparkles, type LucideIcon
} from 'lucide-react';
const CATEGORY_ICONS: Record<string, LucideIcon> = {
@@ -22,6 +22,7 @@ const CATEGORY_ICONS: Record<string, LucideIcon> = {
'Security': Lock,
'File Sharing': Share2,
'System': Cpu,
'AI Tools': Sparkles,
};
const COLOR_MAP: Record<string, string> = {
@@ -40,6 +41,7 @@ const COLOR_MAP: Record<string, string> = {
'green': '#22c55e',
'teal': '#14b8a6',
'gray': '#6b7280',
'fuchsia': '#d946ef',
};
// Category header.

View File

@@ -43,7 +43,8 @@ const categoryColors: Record<Category, string> = {
'Dev: Languages': 'rose',
'Dev: Tools': 'slate',
'Terminal': 'zinc',
'CLI Tools': 'gray'
'CLI Tools': 'gray',
'AI Tools': 'fuchsia'
};
function CategorySectionComponent({

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { distros, apps, type DistroId } from '@/lib/data';
import { distros, apps, type DistroId, isAppAvailable as globalIsAppAvailable } from '@/lib/data';
import { isAurPackage } from '@/lib/aur';
import { isUnfreePackage } from '@/lib/nixUnfree';
@@ -59,8 +59,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
const validApps = appIds.filter(id => {
const app = apps.find(a => a.id === id);
if (!app) return false;
const pkg = app.targets[savedDistro || 'ubuntu'];
return pkg !== undefined && pkg !== null;
return globalIsAppAvailable(app, savedDistro || 'ubuntu');
});
setSelectedApps(new Set(validApps));
}
@@ -131,8 +130,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
const isAppAvailable = useCallback((appId: string): boolean => {
const app = apps.find(a => a.id === appId);
if (!app) return false;
const packageName = app.targets[selectedDistro];
return packageName !== undefined && packageName !== null;
return globalIsAppAvailable(app, selectedDistro);
}, [selectedDistro]);
const getPackageName = useCallback((appId: string): string | null => {
@@ -147,11 +145,8 @@ export function useLinuxInit(): UseLinuxInitReturn {
const newSelected = new Set<string>();
prevSelected.forEach(appId => {
const app = apps.find(a => a.id === appId);
if (app) {
const packageName = app.targets[distroId];
if (packageName !== undefined && packageName !== null) {
newSelected.add(appId);
}
if (app && globalIsAppAvailable(app, distroId)) {
newSelected.add(appId);
}
});
return newSelected;
@@ -161,8 +156,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
const toggleApp = useCallback((appId: string) => {
const app = apps.find(a => a.id === appId);
if (!app) return;
const pkg = app.targets[selectedDistro];
if (pkg === undefined || pkg === null) return;
if (!globalIsAppAvailable(app, selectedDistro)) return;
setSelectedApps(prev => {
const newSet = new Set(prev);
@@ -177,10 +171,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
const selectAll = useCallback(() => {
const allAvailable = apps
.filter(app => {
const pkg = app.targets[selectedDistro];
return pkg !== undefined && pkg !== null;
})
.filter(app => globalIsAppAvailable(app, selectedDistro))
.map(app => app.id);
setSelectedApps(new Set(allAvailable));
}, [selectedDistro]);
@@ -190,10 +181,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
}, []);
const availableCount = useMemo(() => {
return apps.filter(app => {
const pkg = app.targets[selectedDistro];
return pkg !== undefined && pkg !== null;
}).length;
return apps.filter(app => globalIsAppAvailable(app, selectedDistro)).length;
}, [selectedDistro]);
const generatedCommand = useMemo(() => {
@@ -205,57 +193,84 @@ export function useLinuxInit(): UseLinuxInitReturn {
if (!distro) return '';
const packageNames: string[] = [];
const npmPkgs: string[] = [];
const scriptPkgs: string[] = [];
selectedApps.forEach(appId => {
const app = apps.find(a => a.id === appId);
if (app) {
const pkg = app.targets[selectedDistro];
if (pkg) packageNames.push(pkg);
if (pkg) {
packageNames.push(pkg);
} else {
if (app.targets.npm) npmPkgs.push(app.targets.npm);
if (app.targets.script) scriptPkgs.push(app.targets.script);
}
}
});
if (packageNames.length === 0) return '# No packages selected';
const extras: string[] = [];
if (npmPkgs.length > 0) extras.push(`npm install -g ${npmPkgs.join(' ')}`);
const extrasStr = extras.join(' && ');
const appendExtras = (cmd: string) => {
if (!cmd || cmd.startsWith('#')) return extrasStr || cmd;
return extrasStr ? `${cmd} && ${extrasStr}` : cmd;
};
const appendScripts = (cmd: string) => {
if (scriptPkgs.length === 0) return cmd;
const scriptsStr = scriptPkgs.join(' && ');
return cmd ? `${cmd} && ${scriptsStr}` : scriptsStr;
};
if (packageNames.length === 0 && extras.length === 0 && scriptPkgs.length === 0) {
return '# No packages selected';
}
let baseCmd = '';
if (packageNames.length > 0) {
if (selectedDistro === 'nix') {
const sortedPkgs = packageNames.filter(p => p.trim()).sort();
const pkgList = sortedPkgs.map(p => ` ${p}`).join('\\n');
baseCmd = `environment.systemPackages = with pkgs; [\\n${pkgList}\\n];`;
} else if (selectedDistro === 'snap') {
if (packageNames.length === 1) {
baseCmd = `${distro.installPrefix} ${packageNames[0]}`;
} else {
baseCmd = packageNames.map(p => `sudo snap install ${p}`).join(' && ');
}
} else if (selectedDistro === 'arch' && aurPackageInfo.hasAur) {
if (!hasYayInstalled) {
const helperName = selectedHelper; // yay or paru
const installHelperCmd = `sudo pacman -S --needed git base-devel && git clone https://aur.archlinux.org/${helperName}.git /tmp/${helperName} && cd /tmp/${helperName} && makepkg -si --noconfirm && cd - && rm -rf /tmp/${helperName}`;
const installCmd = `${helperName} -S --needed --noconfirm ${packageNames.join(' ')}`;
baseCmd = `${installHelperCmd} && ${installCmd}`;
} else {
baseCmd = `${selectedHelper} -S --needed --noconfirm ${packageNames.join(' ')}`;
}
} else if (selectedDistro === 'homebrew') {
const formulae = packageNames.filter(p => !p.startsWith('--cask '));
const casks = packageNames.filter(p => p.startsWith('--cask ')).map(p => p.replace('--cask ', ''));
const parts: string[] = [];
if (formulae.length > 0) parts.push(`brew install ${formulae.join(' ')}`);
if (casks.length > 0) parts.push(`brew install --cask ${casks.join(' ')}`);
baseCmd = parts.join(' && ');
} else {
baseCmd = `${distro.installPrefix} ${packageNames.join(' ')}`;
}
}
if (selectedDistro === 'nix') {
const sortedPkgs = packageNames.filter(p => p.trim()).sort();
const pkgList = sortedPkgs.map(p => ` ${p}`).join('\n');
return `environment.systemPackages = with pkgs; [\n${pkgList}\n];`;
let combined = baseCmd;
if (extrasStr) combined += '\\n# NPM limits:\\n# ' + extrasStr;
if (scriptPkgs.length > 0) combined += '\\n# Custom scripts:\\n# ' + scriptPkgs.join('\\n# ');
return combined;
}
if (selectedDistro === 'snap') {
if (packageNames.length === 1) {
return `${distro.installPrefix} ${packageNames[0]}`;
}
return packageNames.map(p => `sudo snap install ${p}`).join(' && ');
}
if (selectedDistro === 'arch' && aurPackageInfo.hasAur) {
if (!hasYayInstalled) {
const helperName = selectedHelper; // yay or paru
const installHelperCmd = `sudo pacman -S --needed git base-devel && git clone https://aur.archlinux.org/${helperName}.git /tmp/${helperName} && cd /tmp/${helperName} && makepkg -si --noconfirm && cd - && rm -rf /tmp/${helperName}`;
const installCmd = `${helperName} -S --needed --noconfirm ${packageNames.join(' ')}`;
return `${installHelperCmd} && ${installCmd}`;
} else {
return `${selectedHelper} -S --needed --noconfirm ${packageNames.join(' ')}`;
}
}
if (selectedDistro === 'homebrew') {
const formulae = packageNames.filter(p => !p.startsWith('--cask '));
const casks = packageNames.filter(p => p.startsWith('--cask ')).map(p => p.replace('--cask ', ''));
const parts: string[] = [];
if (formulae.length > 0) {
parts.push(`brew install ${formulae.join(' ')}`);
}
if (casks.length > 0) {
parts.push(`brew install --cask ${casks.join(' ')}`);
}
return parts.join(' && ') || '# No packages selected';
}
return `${distro.installPrefix} ${packageNames.join(' ')}`;
return appendScripts(appendExtras(baseCmd));
}, [selectedDistro, selectedApps, aurPackageInfo.hasAur, hasYayInstalled, selectedHelper]);
return {

128
src/lib/apps/ai-tools.json Normal file
View File

@@ -0,0 +1,128 @@
[
{
"id": "opencode",
"name": "OpenCode",
"description": "AI-powered coding platform and environment",
"category": "AI Tools",
"targets": {
"homebrew": "opencode",
"arch": "opencode",
"nix": "opencode",
"script": "curl -fsSL https://opencode.ai/install | bash"
},
"note": "Falls back to official installer (opencode.ai/install) on distros without a native package.",
"icon": {
"type": "iconify",
"set": "mdi",
"name": "code-braces",
"color": "#3B82F6"
}
},
{
"id": "codex",
"name": "OpenAI Codex",
"description": "AI model that parses natural language and generates code",
"category": "AI Tools",
"targets": {
"npm": "@openai/codex",
"homebrew": "codex",
"nix": "codex"
},
"note": "Requires Node.js runtime. Installed globally via npm where native packages are unavailable.",
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "openai",
"color": "#412991"
}
},
{
"id": "gemini-cli",
"name": "Gemini CLI",
"description": "Command-line interface for Google's Gemini API",
"category": "AI Tools",
"targets": {
"npm": "@google/gemini-cli",
"homebrew": "gemini-cli",
"nix": "gemini-cli"
},
"note": "Requires Node.js runtime. Installed globally via npm where native packages are unavailable.",
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "googlegemini",
"color": "#8E75B2"
}
},
{
"id": "claude-code",
"name": "Claude Code",
"description": "Anthropic's official CLI tool for Claude",
"category": "AI Tools",
"targets": {
"homebrew": "--cask claude-code",
"nix": "claude-code",
"script": "curl -fsSL https://claude.ai/install.sh | bash"
},
"note": "Uses official installer script (claude.ai/install.sh) on Linux distros without a native package.",
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "anthropic",
"color": "#D97757"
}
},
{
"id": "ollama",
"name": "Ollama",
"description": "Get up and running with large language models locally",
"category": "AI Tools",
"targets": {
"fedora": "ollama",
"opensuse": "ollama",
"arch": "ollama",
"nix": "ollama",
"homebrew": "ollama",
"script": "curl -fsSL https://ollama.com/install.sh | sh"
},
"note": "Falls back to official installer (ollama.com/install.sh) on distros without a native package.",
"icon": {
"type": "url",
"url": "https://ollama.com/public/icon-64x64.png"
}
},
{
"id": "llama-cpp",
"name": "llama.cpp",
"description": "Inference of LLaMA model in pure C/C++",
"category": "AI Tools",
"targets": {
"nix": "llama-cpp",
"homebrew": "llama.cpp"
},
"unavailableReason": "Available via [brew or nix](https://github.com/ggml-org/llama.cpp/blob/master/docs/install.md), [Docker](https://github.com/ggml-org/llama.cpp/blob/master/docs/docker.md), pre-built [releases](https://github.com/ggml-org/llama.cpp/releases), or [build from source](https://github.com/ggml-org/llama.cpp/blob/master/docs/build.md).",
"icon": {
"type": "iconify",
"set": "mdi",
"name": "head-cog",
"color": "#64748B"
}
},
{
"id": "jan",
"name": "Jan",
"description": "Open-source ChatGPT-alternative that runs locally offline",
"category": "AI Tools",
"targets": {
"arch": "jan-bin",
"nix": "jan",
"homebrew": "--cask jan",
"flatpak": "ai.jan.Jan"
},
"unavailableReason": "Not in most official repos. Use [flatpak](https://flathub.org/en/apps/ai.jan.Jan) or Download AppImage or .deb package from [jan.ai/download](https://jan.ai/download).",
"icon": {
"type": "url",
"url": "https://www.jan.ai/_next/static/media/logo-jan.db83c5f0.svg"
}
}
]

View File

@@ -1,4 +1,22 @@
[
{
"id": "cursor",
"name": "Cursor",
"description": "AI-powered code editor based on VS Code",
"category": "Dev: Editors",
"targets": {
"arch": "cursor-bin",
"nix": "code-cursor",
"homebrew": "--cask cursor"
},
"unavailableReason": "Only available via [AUR](https://aur.archlinux.org/packages/cursor-bin) or Nix. Download from [cursor.com/download](https://cursor.com/download).",
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "cursor",
"color": "#232020ff"
}
},
{
"id": "vscode",
"name": "VS Code",
@@ -38,97 +56,6 @@
"color": "#2F80ED"
}
},
{
"id": "vim",
"name": "Vim",
"description": "The classic modal text editor that started it all",
"category": "Dev: Editors",
"targets": {
"ubuntu": "vim",
"debian": "vim",
"arch": "vim",
"fedora": "vim-enhanced",
"opensuse": "vim",
"nix": "vim",
"flatpak": "org.vim.Vim",
"snap": "vim-editor",
"homebrew": "vim"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "vim",
"color": "#019733"
}
},
{
"id": "neovim",
"name": "Neovim",
"description": "Modernized Vim with better extensibility",
"category": "Dev: Editors",
"targets": {
"ubuntu": "neovim",
"debian": "neovim",
"arch": "neovim",
"fedora": "neovim",
"opensuse": "neovim",
"nix": "neovim",
"flatpak": "com.neovim.Neovim",
"snap": "nvim --classic",
"homebrew": "neovim"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "neovim",
"color": "#57A143"
}
},
{
"id": "helix",
"name": "Helix",
"description": "Modal editor with LSP and tree-sitter built-in",
"category": "Dev: Editors",
"targets": {
"arch": "helix",
"fedora": "helix",
"opensuse": "helix",
"nix": "helix",
"flatpak": "com.helix-editor.Helix",
"snap": "helix --classic",
"homebrew": "helix"
},
"unavailableReason": "Not in official Debian/Ubuntu repos. For Ubuntu, add the PPA: `sudo add-apt-repository ppa:maveonair/helix-editor && sudo apt update && sudo apt install helix`. For Debian, download .deb from [GitHub releases](https://github.com/helix-editor/helix/releases).",
"icon": {
"type": "iconify",
"set": "mdi",
"name": "dna",
"color": "#4E2F7F"
}
},
{
"id": "micro",
"name": "Micro",
"description": "Easy-to-use terminal text editor like nano",
"category": "Dev: Editors",
"targets": {
"arch": "micro",
"ubuntu": "micro",
"debian": "micro",
"fedora": "micro",
"opensuse": "micro-editor",
"nix": "micro-editor",
"flatpak": "io.github.zyedidia.micro",
"snap": "micro --classic",
"homebrew": "micro"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "microeditor",
"color": "#2E3192"
}
},
{
"id": "zed",
"name": "Zed",
@@ -148,134 +75,6 @@
"color": "#084CCF"
}
},
{
"id": "sublime",
"name": "Sublime Text",
"description": "Lightning-fast proprietary text editor",
"category": "Dev: Editors",
"targets": {
"arch": "sublime-text-4",
"nix": "sublime",
"flatpak": "com.sublimetext.three",
"snap": "sublime-text --classic",
"homebrew": "--cask sublime-text"
},
"unavailableReason": "Not in official repos. Use [Flatpak](https://flathub.org/en/apps/com.sublimetext.three)/[Snap](https://snapcraft.io/sublime-text) or follow instructions at [sublimetext.com/docs/linux_repositories](https://www.sublimetext.com/docs/linux_repositories.html).",
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "sublimetext",
"color": "#FF9800"
}
},
{
"id": "arduino",
"name": "Arduino IDE",
"description": "IDE for Arduino microcontroller development",
"category": "Dev: Editors",
"targets": {
"ubuntu": "arduino",
"debian": "arduino",
"arch": "arduino",
"fedora": "arduino",
"nix": "arduino-ide",
"flatpak": "cc.arduino.IDE2",
"snap": "arduino",
"homebrew": "--cask arduino-ide"
},
"unavailableReason": "Arduino IDE is not in official openSUSE repos. Use [Flatpak](https://flathub.org/en/apps/cc.arduino.IDE2) or [Snap](https://snapcraft.io/arduino) instead.",
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "arduino",
"color": "#00878F"
}
},
{
"id": "cursor",
"name": "Cursor",
"description": "AI-powered code editor based on VS Code",
"category": "Dev: Editors",
"targets": {
"arch": "cursor-bin",
"nix": "code-cursor",
"homebrew": "--cask cursor"
},
"unavailableReason": "Only available via [AUR](https://aur.archlinux.org/packages/cursor-bin) or Nix. Download from [cursor.com/download](https://cursor.com/download).",
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "cursor",
"color": "#232020ff"
}
},
{
"id": "kate",
"name": "Kate",
"description": "Feature-rich text editor by KDE with syntax highlighting",
"category": "Dev: Editors",
"targets": {
"ubuntu": "kate",
"debian": "kate",
"arch": "kate",
"fedora": "kate",
"opensuse": "kate",
"nix": "kdePackages.kate",
"flatpak": "org.kde.kate",
"snap": "kate --classic",
"homebrew": "--cask kate"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "kde",
"color": "#1D99F3"
}
},
{
"id": "emacs",
"name": "Emacs",
"description": "Extensible, customizable, free/libre text editor",
"category": "Dev: Editors",
"targets": {
"ubuntu": "emacs",
"debian": "emacs",
"arch": "emacs",
"fedora": "emacs",
"opensuse": "emacs",
"nix": "emacs",
"flatpak": "org.gnu.emacs",
"snap": "emacs --classic",
"homebrew": "--cask emacs-app"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "gnuemacs",
"color": "#7F5AB6"
}
},
{
"id": "geany",
"name": "Geany",
"description": "Fast and lightweight IDE",
"category": "Dev: Editors",
"targets": {
"ubuntu": "geany",
"debian": "geany",
"arch": "geany",
"fedora": "geany",
"opensuse": "geany",
"nix": "geany",
"flatpak": "org.geany.Geany",
"homebrew": "--cask geany"
},
"unavailableReason": "Snap is unmaintained, download from [Geany](https://www.geany.org/download/releases/) or use [Flatpak](https://flathub.org/en/apps/org.geany.Geany).",
"icon": {
"type": "url",
"url": "https://www.geany.org/static/img/geany.svg"
}
},
{
"id": "intellij-idea",
"name": "Intellij IDEA",
@@ -332,5 +131,206 @@
"set": "logos",
"name": "clion"
}
},
{
"id": "arduino",
"name": "Arduino IDE",
"description": "IDE for Arduino microcontroller development",
"category": "Dev: Editors",
"targets": {
"ubuntu": "arduino",
"debian": "arduino",
"arch": "arduino",
"fedora": "arduino",
"nix": "arduino-ide",
"flatpak": "cc.arduino.IDE2",
"snap": "arduino",
"homebrew": "--cask arduino-ide"
},
"unavailableReason": "Arduino IDE is not in official openSUSE repos. Use [Flatpak](https://flathub.org/en/apps/cc.arduino.IDE2) or [Snap](https://snapcraft.io/arduino) instead.",
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "arduino",
"color": "#00878F"
}
},
{
"id": "sublime",
"name": "Sublime Text",
"description": "Lightning-fast proprietary text editor",
"category": "Dev: Editors",
"targets": {
"arch": "sublime-text-4",
"nix": "sublime",
"flatpak": "com.sublimetext.three",
"snap": "sublime-text --classic",
"homebrew": "--cask sublime-text"
},
"unavailableReason": "Not in official repos. Use [Flatpak](https://flathub.org/en/apps/com.sublimetext.three)/[Snap](https://snapcraft.io/sublime-text) or follow instructions at [sublimetext.com/docs/linux_repositories](https://www.sublimetext.com/docs/linux_repositories.html).",
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "sublimetext",
"color": "#FF9800"
}
},
{
"id": "kate",
"name": "Kate",
"description": "Feature-rich text editor by KDE with syntax highlighting",
"category": "Dev: Editors",
"targets": {
"ubuntu": "kate",
"debian": "kate",
"arch": "kate",
"fedora": "kate",
"opensuse": "kate",
"nix": "kdePackages.kate",
"flatpak": "org.kde.kate",
"snap": "kate --classic",
"homebrew": "--cask kate"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "kde",
"color": "#1D99F3"
}
},
{
"id": "geany",
"name": "Geany",
"description": "Fast and lightweight IDE",
"category": "Dev: Editors",
"targets": {
"ubuntu": "geany",
"debian": "geany",
"arch": "geany",
"fedora": "geany",
"opensuse": "geany",
"nix": "geany",
"flatpak": "org.geany.Geany",
"homebrew": "--cask geany"
},
"unavailableReason": "Snap is unmaintained, download from [Geany](https://www.geany.org/download/releases/) or use [Flatpak](https://flathub.org/en/apps/org.geany.Geany).",
"icon": {
"type": "url",
"url": "https://www.geany.org/static/img/geany.svg"
}
},
{
"id": "neovim",
"name": "Neovim",
"description": "Modernized Vim with better extensibility",
"category": "Dev: Editors",
"targets": {
"ubuntu": "neovim",
"debian": "neovim",
"arch": "neovim",
"fedora": "neovim",
"opensuse": "neovim",
"nix": "neovim",
"flatpak": "com.neovim.Neovim",
"snap": "nvim --classic",
"homebrew": "neovim"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "neovim",
"color": "#57A143"
}
},
{
"id": "vim",
"name": "Vim",
"description": "The classic modal text editor that started it all",
"category": "Dev: Editors",
"targets": {
"ubuntu": "vim",
"debian": "vim",
"arch": "vim",
"fedora": "vim-enhanced",
"opensuse": "vim",
"nix": "vim",
"flatpak": "org.vim.Vim",
"snap": "vim-editor",
"homebrew": "vim"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "vim",
"color": "#019733"
}
},
{
"id": "helix",
"name": "Helix",
"description": "Modal editor with LSP and tree-sitter built-in",
"category": "Dev: Editors",
"targets": {
"arch": "helix",
"fedora": "helix",
"opensuse": "helix",
"nix": "helix",
"flatpak": "com.helix-editor.Helix",
"snap": "helix --classic",
"homebrew": "helix"
},
"unavailableReason": "Not in official Debian/Ubuntu repos. For Ubuntu, add the PPA: `sudo add-apt-repository ppa:maveonair/helix-editor && sudo apt update && sudo apt install helix`. For Debian, download .deb from [GitHub releases](https://github.com/helix-editor/helix/releases).",
"icon": {
"type": "iconify",
"set": "mdi",
"name": "dna",
"color": "#4E2F7F"
}
},
{
"id": "micro",
"name": "Micro",
"description": "Easy-to-use terminal text editor like nano",
"category": "Dev: Editors",
"targets": {
"arch": "micro",
"ubuntu": "micro",
"debian": "micro",
"fedora": "micro",
"opensuse": "micro-editor",
"nix": "micro-editor",
"flatpak": "io.github.zyedidia.micro",
"snap": "micro --classic",
"homebrew": "micro"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "microeditor",
"color": "#2E3192"
}
},
{
"id": "emacs",
"name": "Emacs",
"description": "Extensible, customizable, free/libre text editor",
"category": "Dev: Editors",
"targets": {
"ubuntu": "emacs",
"debian": "emacs",
"arch": "emacs",
"fedora": "emacs",
"opensuse": "emacs",
"nix": "emacs",
"flatpak": "org.gnu.emacs",
"snap": "emacs --classic",
"homebrew": "--cask emacs-app"
},
"icon": {
"type": "iconify",
"set": "simple-icons",
"name": "gnuemacs",
"color": "#7F5AB6"
}
}
]
]

View File

@@ -13,9 +13,11 @@ import vpnNetwork from './apps/vpn-network.json';
import security from './apps/security.json';
import fileSharing from './apps/file-sharing.json';
import system from './apps/system.json';
import aiTools from './apps/ai-tools.json';
export type DistroId = 'ubuntu' | 'debian' | 'arch' | 'fedora' | 'opensuse' | 'nix' | 'flatpak' | 'snap' | 'homebrew';
export type UniversalTargetId = 'npm' | 'script';
export type Category =
| 'Web Browsers'
@@ -32,7 +34,8 @@ export type Category =
| 'VPN & Network'
| 'Security'
| 'File Sharing'
| 'System';
| 'System'
| 'AI Tools';
export type IconDef =
| { type: 'iconify'; set: string; name: string; color?: string }
@@ -52,8 +55,9 @@ export interface AppData {
description: string;
category: Category;
icon: IconDef;
targets: Partial<Record<DistroId, string>>;
targets: Partial<Record<DistroId | UniversalTargetId, string>>;
unavailableReason?: string;
note?: string;
}
export const getIconUrl = (icon: IconDef): string => {
@@ -94,7 +98,8 @@ export const apps: AppData[] = [
...(vpnNetwork as AppData[]),
...(security as AppData[]),
...(fileSharing as AppData[]),
...(system as AppData[])
...(system as AppData[]),
...(aiTools as AppData[])
];
export const categories: Category[] = [
@@ -113,6 +118,7 @@ export const categories: Category[] = [
'Dev: Tools',
'Terminal',
'CLI Tools',
'AI Tools',
];
export const getAppsByCategory = (category: Category): AppData[] => {
@@ -120,5 +126,7 @@ export const getAppsByCategory = (category: Category): AppData[] => {
};
export const isAppAvailable = (app: AppData, distro: DistroId): boolean => {
return distro in app.targets;
return (distro in app.targets) ||
('npm' in app.targets) ||
('script' in app.targets);
};

View File

@@ -1,6 +1,10 @@
import { distros, type DistroId } from './data';
import {
getSelectedPackages,
getUniversalPackages,
generateUniversalScript,
generateAsciiHeader,
generateSharedUtils,
generateUbuntuScript,
generateDebianScript,
generateArchScript,
@@ -22,43 +26,84 @@ export function generateInstallScript(options: GenerateOptions): string {
const { distroId, selectedAppIds, helper = 'yay' } = options;
const distro = distros.find(d => d.id === distroId);
if (!distro) return '#!/bin/bash\necho "Error: Unknown distribution"\nexit 1';
if (!distro) return '#!/bin/bash\\necho "Error: Unknown distribution"\\nexit 1';
const uScript = generateUniversalScript(selectedAppIds, distroId);
const packages = getSelectedPackages(selectedAppIds, distroId);
if (packages.length === 0) return '#!/bin/bash\necho "No packages selected"\nexit 0';
if (packages.length === 0 && !uScript) return '#!/bin/bash\\necho "No packages selected"\\nexit 0';
const injectUniversal = (script: string) => script.replace('\\nprint_summary', '\\n' + uScript + '\\nprint_summary');
let scriptContent = '';
switch (distroId) {
case 'ubuntu': return generateUbuntuScript(packages);
case 'debian': return generateDebianScript(packages);
case 'arch': return generateArchScript(packages, helper);
case 'fedora': return generateFedoraScript(packages);
case 'opensuse': return generateOpenSUSEScript(packages);
case 'nix': return generateNixConfig(packages);
case 'flatpak': return generateFlatpakScript(packages);
case 'snap': return generateSnapScript(packages);
case 'homebrew': return generateHomebrewScript(packages);
default: return '#!/bin/bash\necho "Unsupported distribution"\nexit 1';
case 'ubuntu': scriptContent = injectUniversal(generateUbuntuScript(packages)); break;
case 'debian': scriptContent = injectUniversal(generateDebianScript(packages)); break;
case 'arch': scriptContent = injectUniversal(generateArchScript(packages, helper)); break;
case 'fedora': scriptContent = injectUniversal(generateFedoraScript(packages)); break;
case 'opensuse': scriptContent = injectUniversal(generateOpenSUSEScript(packages)); break;
case 'flatpak': scriptContent = injectUniversal(generateFlatpakScript(packages)); break;
case 'snap': scriptContent = injectUniversal(generateSnapScript(packages)); break;
case 'homebrew': scriptContent = injectUniversal(generateHomebrewScript(packages)); break;
case 'nix':
if (packages.length === 0) return '# Nix\\n\\n# Generic Installers (Please run separately outside NixOS configuration):\\n' + uScript;
return generateNixConfig(packages) + '\\n\\n# ----------------------------------------\\n# NOTE: Universal packages (npm) cannot be strictly placed in environment.systemPackages.\\n# You may need to run these commands in a standard terminal:\\n# \\n/* \\n' + uScript + '\\n*/\\n';
default: return '#!/bin/bash\\necho "Unsupported distribution"\\nexit 1';
}
if (packages.length === 0) {
const universalCount = getUniversalPackages(selectedAppIds, 'npm', distroId).length
+ getUniversalPackages(selectedAppIds, 'script', distroId).length;
return generateAsciiHeader(distro.name, universalCount)
+ generateSharedUtils(distro.name.toLowerCase(), universalCount)
+ uScript
+ '\nprint_summary\n';
}
return scriptContent;
}
export function generateCommandline(options: GenerateOptions): string {
const { selectedAppIds, distroId } = options;
const npmPkgs = getUniversalPackages(selectedAppIds, 'npm', distroId);
const scriptPkgs = getUniversalPackages(selectedAppIds, 'script', distroId);
const extras: string[] = [];
if (npmPkgs.length > 0) extras.push(`npm install -g ${npmPkgs.map(p => p.pkg).join(' ')}`);
const extrasStr = extras.length > 0 ? (extras.join(' && ')) : '';
const appendExtras = (cmd: string) => {
if (!cmd || cmd.startsWith('#')) return extrasStr ? extrasStr : cmd;
return extrasStr ? `${cmd} && ${extrasStr}` : cmd;
};
const appendScripts = (cmd: string) => {
if (scriptPkgs.length === 0) return cmd;
const scriptsStr = scriptPkgs.map(p => p.pkg).join(' && ');
if (!cmd || cmd.startsWith('#')) return scriptsStr;
return `${cmd} && ${scriptsStr}`;
};
const packages = getSelectedPackages(selectedAppIds, distroId);
if (packages.length === 0) return '# No packages selected';
if (packages.length === 0 && extras.length === 0 && scriptPkgs.length === 0) return '# No packages selected';
const pkgList = packages.map(p => p.pkg).join(' ');
switch (distroId) {
case 'ubuntu':
case 'debian': return `sudo apt install -y ${pkgList}`;
case 'arch': return `yay -S --needed --noconfirm ${pkgList}`;
case 'fedora': return `sudo dnf install -y ${pkgList}`;
case 'opensuse': return `sudo zypper install -y ${pkgList}`;
case 'nix': return generateNixConfig(packages);
case 'flatpak': return `flatpak install flathub -y ${pkgList}`;
case 'snap':
if (packages.length === 1) return `sudo snap install ${pkgList}`;
return packages.map(p => `sudo snap install ${p.pkg}`).join(' && ');
case 'debian': return appendScripts(appendExtras(pkgList ? `sudo apt install -y ${pkgList}` : ''));
case 'arch': return appendScripts(appendExtras(pkgList ? `yay -S --needed --noconfirm ${pkgList}` : ''));
case 'fedora': return appendScripts(appendExtras(pkgList ? `sudo dnf install -y ${pkgList}` : ''));
case 'opensuse': return appendScripts(appendExtras(pkgList ? `sudo zypper install -y ${pkgList}` : ''));
case 'nix': return generateNixConfig(packages); // Nix handles its own thing without extras as simple cmds
case 'flatpak': return appendScripts(appendExtras(pkgList ? `flatpak install flathub -y ${pkgList}` : ''));
case 'snap': {
let cmd = '';
if (packages.length === 1) cmd = `sudo snap install ${pkgList}`;
else if (packages.length > 1) cmd = packages.map(p => `sudo snap install ${p.pkg}`).join(' && ');
return appendScripts(appendExtras(cmd));
}
case 'homebrew': {
const formulae = packages.filter(p => !p.pkg.startsWith('--cask '));
const casks = packages.filter(p => p.pkg.startsWith('--cask '));
@@ -69,8 +114,8 @@ export function generateCommandline(options: GenerateOptions): string {
if (casks.length > 0) {
parts.push(`brew install --cask ${casks.map(p => p.pkg.replace('--cask ', '')).join(' ')}`);
}
return parts.join(' && ') || '# No packages selected';
return appendScripts(appendExtras(parts.join(' && ')));
}
default: return `# Install: ${pkgList}`;
default: return appendScripts(appendExtras(pkgList ? `# Install: ${pkgList}` : ''));
}
}

View File

@@ -26,6 +26,7 @@
"jetbrains.idea",
"jetbrains.pycharm",
"jetbrains.clion",
"microsoft-edge"
"microsoft-edge",
"claude-code"
]
}

View File

@@ -1,6 +1,6 @@
export { escapeShellString, getSelectedPackages, type PackageInfo } from './shared';
export { escapeShellString, getSelectedPackages, getUniversalPackages, generateUniversalScript, generateAsciiHeader, generateSharedUtils, type PackageInfo } from './shared';
export { generateUbuntuScript } from './ubuntu';
export { generateDebianScript } from './debian';
export { generateArchScript } from './arch';

View File

@@ -1,4 +1,4 @@
import { apps, type DistroId, type AppData } from '../data';
import { apps, type DistroId, type AppData, type UniversalTargetId } from '../data';
export interface PackageInfo {
app: AppData;
@@ -21,6 +21,33 @@ export function getSelectedPackages(selectedAppIds: Set<string>, distroId: Distr
.map(app => ({ app, pkg: app.targets[distroId]! }));
}
export function getUniversalPackages(selectedAppIds: Set<string>, target: UniversalTargetId, distroId?: DistroId): PackageInfo[] {
return Array.from(selectedAppIds)
.map(id => apps.find(a => a.id === id))
.filter((app): app is AppData => !!app && !!app.targets[target] && (!distroId || !app.targets[distroId]))
.map(app => ({ app, pkg: app.targets[target]! }));
}
export function generateUniversalScript(selectedAppIds: Set<string>, distroId?: DistroId): string {
let script = '';
const npmPkgs = getUniversalPackages(selectedAppIds, 'npm', distroId);
const scriptPkgs = getUniversalPackages(selectedAppIds, 'script', distroId);
if (npmPkgs.length === 0 && scriptPkgs.length === 0) {
return '';
}
if (npmPkgs.length > 0) {
script += `if command -v npm >/dev/null 2>&1; then\\n info "Installing Node.js (npm) packages..."\\n${npmPkgs.map(p => ` with_retry npm install -g ${escapeShellString(p.pkg)}`).join('\\n')}\\nelse\\n warn "npm is not installed. Skipping: ${npmPkgs.map(p => escapeShellString(p.app.name)).join(', ')}"\\nfi\\n\\n`;
}
if (scriptPkgs.length > 0) {
script += `info "Running custom install scripts..."\\n${scriptPkgs.map(p => `info "Running script for ${escapeShellString(p.app.name)}..."\\n${p.pkg}`).join('\\n')}\\n\\n`;
}
return script;
}
export function generateAsciiHeader(distroName: string, pkgCount: number): string {
const date = new Date().toISOString().split('T')[0];
return `#!/bin/bash

View File

@@ -1,6 +1,6 @@
{
"meta": {
"fetchedAt": "2026-03-25T08:54:15.513Z"
"fetchedAt": "2026-03-26T11:12:34.224Z"
},
"count": 702,
"apps": [
@@ -174,6 +174,7 @@
"com.tomjwatson.Emote",
"com.toolstack.Folio",
"com.ulaa.Ulaa",
"com.unicornsonlsd.finamp",
"com.usebottles.bottles",
"com.usebruno.Bruno",
"com.valvesoftware.SteamLink",
@@ -227,7 +228,6 @@
"eu.jumplink.Learn6502",
"eu.nokun.MirrorHall",
"eu.vcmi.VCMI",
"fr.arnaudmichel.launcherstudio",
"fr.handbrake.ghb",
"fr.quentium.acters",
"garden.jamie.Morphosis",
@@ -255,6 +255,7 @@
"io.frama.tractor.carburetor",
"io.freetubeapp.FreeTube",
"io.gdevelop.ide",
"io.github.AshBuk.FingerGo",
"io.github.BrisklyDev.Brisk",
"io.github.CyberTimon.RapidRAW",
"io.github.DenysMb.Kontainer",
@@ -326,7 +327,6 @@
"io.github.linx_systems.ClamUI",
"io.github.lluciocc.Vish",
"io.github.lullabyX.sone",
"io.github.maddecoder.Classic-RBDOOM-3-BFG",
"io.github.marco_calautti.DeltaPatcher",
"io.github.martchus.syncthingtray",
"io.github.martinrotter.rssguard",
@@ -357,7 +357,6 @@
"io.github.realmazharhussain.GdmSettings",
"io.github.revisto.drum-machine",
"io.github.rfrench3.scopebuddy-gui",
"io.github.schwarzen.colormydesktop",
"io.github.seadve.Kooha",
"io.github.seadve.Mousai",
"io.github.sepehr_rs.Sudoku",
@@ -444,6 +443,7 @@
"net.sapples.LiveCaptions",
"net.shadps4.shadPS4",
"net.sourceforge.VMPK",
"net.sourceforge.m64py.M64Py",
"net.supertuxkart.SuperTuxKart",
"net.trowell.typesetter",
"net.waterfox.waterfox",