Skip to content
Open
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
64 changes: 52 additions & 12 deletions src/cli/skill-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ import {
matchesSkillUpdateFilter,
resolveCheckoutSubpath,
} from '../core/skill-update.js';
import { discoverSkillEntriesFromPluginRoot } from '../core/skills.js';
import {
type DiscoveredSkillEntry,
discoverSkillEntriesFromPluginRoot,
} from '../core/skills.js';
import { syncUserWorkspace, syncWorkspace } from '../core/sync.js';
import { getUserWorkspaceConfigPath } from '../core/user-workspace.js';
import type {
Expand All @@ -47,6 +50,7 @@ import {
} from '../models/workspace-config.js';
import { parseMarketplaceManifest } from '../utils/marketplace-manifest-parser.js';
import {
formatPluginSource,
getPluginCachePath,
isGitHubUrl,
parseGitHubUrl,
Expand Down Expand Up @@ -142,6 +146,12 @@ export interface SkillUpdateInventory {
installations: SkillUpdateInstallation[];
skippedLocalSources: string[];
failures: SkillUpdateInventoryFailure[];
/** Direct config entries consuming a cache, including those with no enabled skills. */
directRemoteConsumers: Array<{
scope: SkillUpdateScope;
source: string;
nodeId: string;
}>;
}

export interface PrepareSkillUpdateOptions {
Expand Down Expand Up @@ -236,7 +246,7 @@ function posixPath(path: string): string {
}

function enabledSkills(
entries: Awaited<ReturnType<typeof discoverSkillEntriesFromPluginRoot>>,
entries: DiscoveredSkillEntry[],
pluginName: string,
config: WorkspaceConfig,
pluginSkills: PluginSkillsConfig | undefined,
Expand Down Expand Up @@ -288,16 +298,21 @@ function pluginSkillsConfig(
return typeof plugin === 'string' ? undefined : plugin.skills;
}

async function isStandaloneSkillRoot(
function isStandaloneSkillRoot(
root: string,
entries: Awaited<ReturnType<typeof discoverSkillEntriesFromPluginRoot>>,
): Promise<boolean> {
if (!existsSync(join(root, 'SKILL.md')) || entries.length !== 1) return false;
entries: DiscoveredSkillEntry[],
): boolean {
if (
!existsSync(join(root, 'SKILL.md')) ||
entries.length !== 1 ||
resolve(entries[0]?.skillPath ?? '') !== resolve(root)
) {
return false;
}
return ![
'.claude-plugin',
'.github',
'.mcp.json',
'agents',
'commands',
'hooks',
'mcp.json',
Expand Down Expand Up @@ -381,7 +396,7 @@ async function inventoryDirect(
pluginName,
currentSha: await revision(cachePath),
skills,
standaloneSkillSource: await isStandaloneSkillRoot(root, discovered),
standaloneSkillSource: isStandaloneSkillRoot(root, discovered),
});
}

Expand Down Expand Up @@ -498,6 +513,8 @@ export async function buildSkillUpdateInventory(
const installations: SkillUpdateInstallation[] = [];
const skippedLocalSources: string[] = [];
const failures: SkillUpdateInventoryFailure[] = [];
const directRemoteConsumers: SkillUpdateInventory['directRemoteConsumers'] =
[];
const deferred: SkillUpdateInstallation[] = [];
const deferredErrors: Array<
SkillUpdateInventoryFailure & { errorCause: unknown }
Expand Down Expand Up @@ -549,6 +566,16 @@ export async function buildSkillUpdateInventory(
for (const [configIndex, plugin] of config.plugins.entries()) {
const rawSource = getPluginSource(plugin);
const effectiveSource = getEffectivePluginSource(plugin);
const direct = isGitHubUrl(effectiveSource)
? parseGitHubUrl(effectiveSource)
: null;
if (direct) {
directRemoteConsumers.push({
scope,
source: rawSource,
nodeId: getPluginCachePath(direct.owner, direct.repo, direct.branch),
});
}
let installation: SkillUpdateInstallation | null | 'local';
try {
// Inline Git refs also use `@` (owner/repo@ref). Direct GitHub
Expand Down Expand Up @@ -630,7 +657,12 @@ export async function buildSkillUpdateInventory(
error: `Could not safely inventory shared source: ${sharedFailure.errorCause instanceof Error ? sharedFailure.errorCause.message : String(sharedFailure.errorCause)}`,
});
}
return { installations, skippedLocalSources, failures };
return {
installations,
skippedLocalSources,
failures,
directRemoteConsumers,
};
}

async function inspectInstallation(
Expand Down Expand Up @@ -939,10 +971,18 @@ export function hasProjectSkillConfig(workspacePath: string): boolean {
export function unitDisplayName(
unit: SkillUpdatePreflight['units'][number],
): string {
const sources = [
...new Set(unit.installations.map((entry) => entry.rawSource)),
const labels = [
...new Set(
unit.installations.flatMap((installation) =>
installation.standaloneSkillSource
? installation.skills
.filter((skill) => skill.enabled)
.map((skill) => skill.name)
: [formatPluginSource(installation.rawSource)],
),
),
];
return sources.length > 0 ? sources.join(', ') : basename(unit.id);
return labels.length > 0 ? labels.join(', ') : basename(unit.id);
}

export interface SkillUpdateSummary {
Expand Down
144 changes: 133 additions & 11 deletions src/cli/tui/actions/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
type MarketplaceEntry,
type MarketplacePluginsResult,
} from '../../../core/marketplace.js';
import { updatePlugin } from '../../../core/plugin.js';
import { resetFetchCache, updatePlugin } from '../../../core/plugin.js';
import { formatVerboseSyncLines } from '../../format-sync.js';
import { parseMarketplaceManifest } from '../../../utils/marketplace-manifest-parser.js';
import { getWorkspaceStatus } from '../../../core/status.js';
Expand All @@ -32,6 +32,19 @@ import { getHomeDir } from '../../../constants.js';
import type { TuiContext } from '../context.js';
import type { TuiCache } from '../cache.js';
import { removeInstalledSkill } from '../../skill-removal.js';
import {
buildSkillUpdateInventory,
executePreparedSkillUpdate,
inspectSkillUpdateUnit,
resolveNonInteractiveSkillUpdateDecisions,
unitDisplayName,
} from '../../skill-update.js';
import {
buildPhysicalRefreshUnits,
buildSkillUpdatePreflight,
type SkillUpdatePreflight,
type SkillUpdateScope,
} from '../../../core/skill-update.js';

const { select, text, confirm, multiselect, autocomplete } = p;

Expand Down Expand Up @@ -229,31 +242,140 @@ export async function runUpdateAllPlugins(
const results: Array<{ plugin: string; action: string; error?: string }> = [];
let needsProjectSync = false;
let needsUserSync = false;
const scopes = [
...new Set(pluginsToUpdate.map(({ scope }) => scope)),
] as SkillUpdateScope[];
const workspacePath = context.workspacePath ?? process.cwd();
const inventory = await buildSkillUpdateInventory(workspacePath, scopes);
const standaloneIds = new Set(
inventory.installations
.filter(
(installation) =>
installation.standaloneSkillSource &&
scopes.includes(installation.scope),
)
.map((installation) => installation.id),
);
const standaloneUnits = buildPhysicalRefreshUnits(
inventory.installations,
).filter((unit) =>
unit.installations.some((installation) =>
standaloneIds.has(installation.id),
),
);
const handledPlugins = new Set<string>();
let standalonePlan: SkillUpdatePreflight | undefined;

if (standaloneUnits.length > 0) {
const installations = standaloneUnits.flatMap(
(unit) => unit.installations,
);
const nodeIds = new Set(
standaloneUnits.flatMap((unit) => unit.nodes.map((node) => node.id)),
);
const failures = inventory.failures.filter((failure) =>
failure.nodeIds.some((nodeId) => nodeIds.has(nodeId)),
);
standalonePlan = await buildSkillUpdatePreflight(
{
installations,
selectedScopes: scopes,
failures,
},
{ inspectUnit: inspectSkillUpdateUnit },
);

for (const installation of installations) {
if (scopes.includes(installation.scope)) {
handledPlugins.add(`${installation.scope}:${installation.rawSource}`);
}
}
for (const failure of failures) {
handledPlugins.add(`${failure.scope}:${failure.source}`);
}
for (const consumer of inventory.directRemoteConsumers) {
if (
scopes.includes(consumer.scope) &&
nodeIds.has(consumer.nodeId)
) {
handledPlugins.add(`${consumer.scope}:${consumer.source}`);
}
}
}

resetFetchCache();
// Refresh generic sources before standalone execution performs its offline
// scope sync, otherwise that sync's fetch-cache entries can mask updates.
for (const { spec, scope } of pluginsToUpdate) {
const result = await updatePlugin(spec, scope === 'project' ? projectDeps : userDeps);
if (handledPlugins.has(`${scope}:${spec}`)) continue;
const result = await updatePlugin(
spec,
scope === 'project' ? projectDeps : userDeps,
);
const entry: { plugin: string; action: string; error?: string } = {
plugin: spec,
action: result.action,
};
if (result.error) {
entry.error = result.error;
}
if (result.error) entry.error = result.error;
results.push(entry);
if (result.action === 'updated') {
if (scope === 'project') needsProjectSync = true;
else needsUserSync = true;
}
}

// Sync if any plugins were updated
if (needsProjectSync || needsUserSync) {
const standaloneSyncedScopes = new Set<SkillUpdateScope>();
if (standalonePlan) {
const prepared = { inventory, plan: standalonePlan };
const execution = await executePreparedSkillUpdate(
prepared,
resolveNonInteractiveSkillUpdateDecisions(standalonePlan),
workspacePath,
);
const planById = new Map(
standalonePlan.units.map((unit) => [unit.id, unit]),
);

for (const scope of execution.syncedScopes) {
standaloneSyncedScopes.add(scope);
}
for (const result of execution.units) {
const unit = planById.get(result.id);
const action =
result.status === 'updated' || result.status === 'removed'
? 'updated'
: result.status === 'failed'
? 'failed'
: 'skipped';
results.push({
plugin: unit ? unitDisplayName(unit) : result.id,
action,
...(result.error && { error: result.error }),
});
}
if (execution.units.some((result) =>
result.status === 'updated' || result.status === 'removed'
)) {
cache?.invalidate();
}
}

// Generic sources have already refreshed above. Materialize from those cache
// revisions without letting a retained or failed standalone unit advance.
if (
(needsProjectSync && !standaloneSyncedScopes.has('project')) ||
(needsUserSync && !standaloneSyncedScopes.has('user'))
) {
s.message('Updating...');
if (needsProjectSync && context.workspacePath) {
await syncWorkspace(context.workspacePath);
if (
needsProjectSync &&
!standaloneSyncedScopes.has('project') &&
context.workspacePath
) {
await syncWorkspace(context.workspacePath, { offline: true });
}
if (needsUserSync) {
await syncUserWorkspace();
if (needsUserSync && !standaloneSyncedScopes.has('user')) {
await syncUserWorkspace({ offline: true });
}
cache?.invalidate();
}
Expand Down
Loading
Loading