Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion plug/src/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,26 @@ export async function runInstall(name, options = {}) {
const pkgSpecMap = new Map();
pkgSpecMap.set(pkgName, { pkg, vault, rawBaseUrl: pkg.rawBaseUrl ?? null });

const unresolvedDeps = [];
for (const depName of installOrder) {
if (depName === pkgName || pkgSpecMap.has(depName)) continue;
try {
const depMatches = await findAllPackages(depName);
if (depMatches.length > 0) {
const { pkg: depPkg, vault: depVault } = depMatches[0];
pkgSpecMap.set(depName, { pkg: depPkg, vault: depVault, rawBaseUrl: depPkg.rawBaseUrl ?? null });
} else {
unresolvedDeps.push(depName);
verbose(`Dependency '${depName}' not found in any vault — skipping`);
}
} catch {
// Skip unresolvable deps silently
unresolvedDeps.push(depName);
verbose(`Dependency '${depName}' lookup failed — skipping`);
}
}
if (unresolvedDeps.length > 0 && !ctx.json) {
console.log(chalk.yellow(`⚠ Could not resolve ${unresolvedDeps.length} dependenc${unresolvedDeps.length === 1 ? 'y' : 'ies'}: ${unresolvedDeps.join(', ')}`));
}

// Root's direct dependency names (for tracking)
const rootDirectDeps = (pkg.dependencies ?? []).map(d => (typeof d === 'string' ? d : d.name));
Expand Down
10 changes: 6 additions & 4 deletions plug/src/tui/components/package-detail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ export default function PackageDetail({ pkg, onBack, onInstall, installedNames =

{/* Metadata */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text dimColor>Version : </Text>
<Text>{pkg.version || '?'}</Text>
</Box>
{pkg.version && (
<Box>
<Text dimColor>Version : </Text>
<Text>{pkg.version}</Text>
</Box>
)}
<Box>
<Text dimColor>Vault : </Text>
<Text>{pkg.vault}</Text>
Expand Down
5 changes: 3 additions & 2 deletions plug/src/tui/components/status-line.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ import { Box, Text } from 'ink';
* searchFocused: boolean,
* }} props
*/
export default function StatusLine({ cursor, total, filtered, isFiltered, searchFocused }) {
export default function StatusLine({ cursor, total, filtered, isFiltered, searchFocused, typeFilter = 'all' }) {
const position = total > 0 ? `${cursor + 1}/${filtered}` : '0/0';
const filterNote = isFiltered ? ` (filtered from ${total})` : '';
const searchHint = searchFocused ? ' · type to filter · Esc to clear' : '';
const typeLabel = typeFilter !== 'all' ? ` · [${typeFilter}s]` : '';

return (
<Box paddingX={1}>
<Text dimColor>
Discover plugins {position}{filterNote}{searchHint}
Discover plugins {position}{filterNote}{typeLabel}{searchHint}
</Text>
</Box>
);
Expand Down
4 changes: 3 additions & 1 deletion plug/src/tui/hooks/use-installed.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@ export function useInstalled() {
]);

const merged = [];
const localPaths = new Map();

for (const [name, record] of Object.entries(localData.installed || {})) {
merged.push({ name, ...record, scope: 'local', hasUpdate: false, latestVersion: null });
localPaths.set(name, record.path);
}
for (const [name, record] of Object.entries(globalData.installed || {})) {
// Deduplicate: if same name in both scopes, keep both (different scope label)
if (localPaths.has(name) && localPaths.get(name) === record.path) continue;
merged.push({ name, ...record, scope: 'global', hasUpdate: false, latestVersion: null });
}

Expand Down
2 changes: 1 addition & 1 deletion plug/src/tui/hooks/use-packages.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function addVaultPackages(list, vaultName, registry) {
list.push({
name,
vault: vaultName,
version: pkg.version || '?',
version: pkg.version || null,
type: pkg.type || 'skill',
description: pkg.description || '',
tags: pkg.tags || [],
Expand Down
34 changes: 34 additions & 0 deletions plug/src/tui/hooks/use-vaults.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { getConfig } from '../../utils/config.js';
import { getCachedRegistry } from '../../utils/registry.js';
import { getCachedCommunityIndex, getStaleCommunityIndexCache } from '../../utils/community-index.js';

/**
* Loads vault configuration and enriches each vault with metadata:
Expand Down Expand Up @@ -68,6 +69,39 @@ export function useVaults() {
});
}

const communityData = await getCachedCommunityIndex() || await getStaleCommunityIndexCache();
if (communityData) {
const pkgs = communityData.packages || [];
const communityVaultMap = new Map();
for (const pkg of pkgs) {
if (!pkg.vault || enriched.some(v => v.name === pkg.vault)) continue;
if (!communityVaultMap.has(pkg.vault)) {
communityVaultMap.set(pkg.vault, { count: 0, url: pkg.vaultUrl || '' });
}
communityVaultMap.get(pkg.vault).count++;
}
for (const [name, info] of communityVaultMap) {
let owner = '';
let repo = '';
try {
const parts = new URL(info.url).pathname.split('/').filter(Boolean);
owner = parts[0] || '';
repo = parts[1] || '';
} catch {}
enriched.push({
name,
owner,
repo,
branch: 'main',
private: false,
isDefault: false,
githubUrl: info.url,
packageCount: info.count,
isCommunity: true,
});
}
}

if (!cancelled) {
setVaults(enriched);
setLoading(false);
Expand Down
25 changes: 17 additions & 8 deletions plug/src/tui/screens/discover.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,16 @@ export default function DiscoverScreen({ onInputCapture }) {
const [installedNames, setInstalledNames] = useState(new Set());
const [depPlan, setDepPlan] = useState(null);
const [planLoading, setPlanLoading] = useState(false);
const [typeFilter, setTypeFilter] = useState('all');
const installingRef = useRef(false);

const TYPE_FILTERS = ['all', 'skill', 'agent', 'command'];

// Filtered/searched package list
const filteredPackages = useSearch(packages, searchQuery);
const searchedPackages = useSearch(packages, searchQuery);
const filteredPackages = typeFilter === 'all'
? searchedPackages
: searchedPackages.filter(p => p.type === typeFilter);

// Load installed package names
useEffect(() => {
Expand Down Expand Up @@ -76,6 +82,14 @@ export default function DiscoverScreen({ onInputCapture }) {
return;
}

if (input === 't') {
setTypeFilter((prev) => {
const idx = TYPE_FILTERS.indexOf(prev);
return TYPE_FILTERS[(idx + 1) % TYPE_FILTERS.length];
});
return;
}

// Action keys only fire when search is unfocused
if (input === 'i' && filteredPackages.length > 0) {
const queue = toggled.size > 0
Expand Down Expand Up @@ -148,13 +162,7 @@ export default function DiscoverScreen({ onInputCapture }) {
.then((plan) => {
setPlanLoading(false);
setDepPlan(plan);
// Single package (no extra deps) → skip plan screen, install immediately
if (plan.toInstall.length <= 1) {
setView('installing');
installingRef.current = true;
doInstall(queue).finally(() => { installingRef.current = false; });
}
// else: stay on plan view so user can confirm
// Always show plan screen so user can choose project vs global scope
})
.catch(() => {
// Resolver failed — fall back to direct install
Expand Down Expand Up @@ -327,6 +335,7 @@ export default function DiscoverScreen({ onInputCapture }) {
filtered={filteredPackages.length}
isFiltered={isFiltered}
searchFocused={searchFocused}
typeFilter={typeFilter}
/>
</Box>
);
Expand Down
10 changes: 6 additions & 4 deletions plug/src/tui/screens/installed.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,12 @@ function InstalledDetail({ pkg, onBack, onUpdate, onRemove }) {
</Box>

<Box flexDirection="column" marginBottom={1}>
<Box>
<Text dimColor>Version : </Text>
<Text>{pkg.version || '?'}</Text>
</Box>
{pkg.version && (
<Box>
<Text dimColor>Version : </Text>
<Text>{pkg.version}</Text>
</Box>
)}
<Box>
<Text dimColor>Vault : </Text>
<Text>{pkg.vault || '?'}</Text>
Expand Down
19 changes: 11 additions & 8 deletions plug/src/tui/screens/vaults.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,15 @@ export default function VaultsScreen({ onInputCapture }) {

if (input === 'r' && vaults.length > 0) {
const vault = vaults[cursor];
if (vault) {
if (vault && !vault.isCommunity) {
setPendingRemove(vault);
setView('confirm-remove');
}
}

if (input === 'd' && vaults.length > 0) {
const vault = vaults[cursor];
if (vault && !vault.isDefault) {
if (vault && !vault.isDefault && !vault.isCommunity) {
doSetDefault(vault.name);
}
}
Expand Down Expand Up @@ -277,10 +277,9 @@ function VaultList({ vaults, cursor, terminalWidth }) {
function VaultItem({ vault, isCursor, terminalWidth }) {
const star = vault.isDefault ? '★' : ' ';
const cursorStr = isCursor ? '>' : ' ';
const privLabel = vault.private ? '[private]' : '[public] ';
const privLabel = vault.isCommunity ? '[community]' : vault.private ? '[private]' : '[public] ';
const pkgStr = vault.packageCount !== null ? `${vault.packageCount} pkgs` : '? pkgs';

// Line 1: cursor star name · owner/repo · branch · visibility · pkg count
const line1 = `${vault.name} · ${vault.owner}/${vault.repo} · ${vault.branch} · ${privLabel} · ${pkgStr}`;
const prefixLen = 4; // " > "
const maxLine1 = terminalWidth - prefixLen - 2;
Expand Down Expand Up @@ -320,11 +319,11 @@ function VaultStatusLine({ cursor, total }) {
// ── ConfirmRemoveVault ────────────────────────────────────────────────────────

function ConfirmRemoveVault({ vault, onConfirm, onCancel }) {
const isOfficial = vault.name === 'official';
const isProtected = vault.name === 'official' || vault.isCommunity;

useInput((input, key) => {
if (key.escape || input === 'n' || input === 'N') onCancel();
if (!isOfficial && (input === 'y' || input === 'Y')) onConfirm();
if (!isProtected && (input === 'y' || input === 'Y')) onConfirm();
});

return (
Expand All @@ -333,10 +332,14 @@ function ConfirmRemoveVault({ vault, onConfirm, onCancel }) {
<Text bold color="red">Remove Vault</Text>
</Box>

{isOfficial ? (
{isProtected ? (
<>
<Box marginBottom={1}>
<Text color="yellow">Cannot remove the official vault — it is required for plug to function.</Text>
<Text color="yellow">
{vault.isCommunity
? 'Cannot remove community vault sources — they are read-only.'
: 'Cannot remove the official vault — it is required for plug to function.'}
</Text>
</Box>
<Box>
<Text dimColor>Press Esc or [n] to go back</Text>
Expand Down
2 changes: 1 addition & 1 deletion plug/src/utils/community-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function normalizeCommunityPackage(pkg) {
name: pkg.name,
vault: pkg.vault,
vaultUrl: pkg.vaultUrl,
version: pkg.version ?? '?',
version: pkg.version || null,
type: pkg.type,
description: pkg.description ?? '',
tags: pkg.tags ?? [],
Expand Down
8 changes: 4 additions & 4 deletions plug/tests/community-index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,15 @@ describe('community-index utils', () => {
expect(result.path).toBe(base.directory);
});

it('version defaults to "?" when null', () => {
it('version defaults to null when null', () => {
const result = normalizeCommunityPackage({ ...base, version: null });
expect(result.version).toBe('?');
expect(result.version).toBeNull();
});

it('version defaults to "?" when undefined', () => {
it('version defaults to null when undefined', () => {
const { version: _v, ...noVersion } = base;
const result = normalizeCommunityPackage(noVersion);
expect(result.version).toBe('?');
expect(result.version).toBeNull();
});

it('tags defaults to [] when null', () => {
Expand Down
3 changes: 2 additions & 1 deletion plug/tests/community-install-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { installSinglePackage } from '../src/commands/install.js';
import { fetchCommunityIndex, normalizeCommunityPackage } from '../src/utils/community-index.js';
Expand All @@ -28,7 +29,7 @@ import { ctx } from '../src/utils/context.js';
const LOCAL_INDEX = process.env.LOCAL_INDEX || '';

const OUTPUT_DIR = process.env.OUTPUT_DIR
|| path.resolve(import.meta.dirname, '..', 'test-output', 'community-install');
|| path.join(os.tmpdir(), 'plug-test-output', 'community-install');
const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : Infinity;
const FILTER = process.env.FILTER || '';

Expand Down
5 changes: 3 additions & 2 deletions plug/tests/tui/discover.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,10 @@ describe('DiscoverScreen', () => {
stdin.write('i');
await new Promise((r) => setTimeout(r, 200));

// Should go directly to installing view (not plan view)
// Should now show plan view with scope selector even for single package
const frame = lastFrame();
expect(frame).not.toContain('Will install');
expect(frame).toContain('Will install');
expect(frame).toContain('Project');
});

it('Tab key toggles scope between Project and Global in plan view', async () => {
Expand Down
Loading