style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -1,37 +1,34 @@
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import * as React from 'react';
import { useEffect, useRef, useState } from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
import { Spinner } from '../../components/Spinner.js'
import TextInput from '../../components/TextInput.js'
import { Box, Text } from '@anthropic/ink'
import { toError } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
import {
addMarketplaceSource,
saveMarketplaceToSettings,
} from '../../utils/plugins/marketplaceManager.js'
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js'
import type { ViewState } from './types.js'
} from 'src/services/analytics/index.js';
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
import { Spinner } from '../../components/Spinner.js';
import TextInput from '../../components/TextInput.js';
import { Box, Text } from '@anthropic/ink';
import { toError } from '../../utils/errors.js';
import { logError } from '../../utils/log.js';
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js';
import { addMarketplaceSource, saveMarketplaceToSettings } from '../../utils/plugins/marketplaceManager.js';
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js';
import type { ViewState } from './types.js';
type Props = {
inputValue: string
setInputValue: (value: string) => void
cursorOffset: number
setCursorOffset: (offset: number) => void
error: string | null
setError: (error: string | null) => void
result: string | null
setResult: (result: string | null) => void
setViewState: (state: ViewState) => void
onAddComplete?: () => void | Promise<void>
cliMode?: boolean
}
inputValue: string;
setInputValue: (value: string) => void;
cursorOffset: number;
setCursorOffset: (offset: number) => void;
error: string | null;
setError: (error: string | null) => void;
result: string | null;
setResult: (result: string | null) => void;
setViewState: (state: ViewState) => void;
onAddComplete?: () => void | Promise<void>;
cliMode?: boolean;
};
export function AddMarketplace({
inputValue,
@@ -46,94 +43,87 @@ export function AddMarketplace({
onAddComplete,
cliMode = false,
}: Props): React.ReactNode {
const hasAttemptedAutoAdd = useRef(false)
const [isLoading, setLoading] = useState(false)
const [progressMessage, setProgressMessage] = useState<string>('')
const hasAttemptedAutoAdd = useRef(false);
const [isLoading, setLoading] = useState(false);
const [progressMessage, setProgressMessage] = useState<string>('');
const handleAdd = async () => {
const input = inputValue.trim()
const input = inputValue.trim();
if (!input) {
setError('Please enter a marketplace source')
return
setError('Please enter a marketplace source');
return;
}
const parsed = await parseMarketplaceInput(input)
const parsed = await parseMarketplaceInput(input);
if (!parsed) {
setError(
'Invalid marketplace source format. Try: owner/repo, https://..., or ./path',
)
return
setError('Invalid marketplace source format. Try: owner/repo, https://..., or ./path');
return;
}
// Check if parseMarketplaceInput returned an error
if ('error' in parsed) {
setError(parsed.error)
return
setError(parsed.error);
return;
}
setError(null)
setError(null);
try {
setLoading(true)
setProgressMessage('')
const { name, resolvedSource } = await addMarketplaceSource(
parsed,
message => {
setProgressMessage(message)
},
)
saveMarketplaceToSettings(name, { source: resolvedSource })
clearAllCaches()
setLoading(true);
setProgressMessage('');
const { name, resolvedSource } = await addMarketplaceSource(parsed, message => {
setProgressMessage(message);
});
saveMarketplaceToSettings(name, { source: resolvedSource });
clearAllCaches();
let sourceType = parsed.source
let sourceType = parsed.source;
if (parsed.source === 'github') {
sourceType =
parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
sourceType = parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS;
}
logEvent('tengu_marketplace_added', {
source_type:
sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
source_type: sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
if (onAddComplete) {
await onAddComplete()
await onAddComplete();
}
setProgressMessage('')
setLoading(false)
setProgressMessage('');
setLoading(false);
if (cliMode) {
// In CLI mode, set result to trigger completion
setResult(`Successfully added marketplace: ${name}`)
setResult(`Successfully added marketplace: ${name}`);
} else {
// In interactive mode, switch to browse view
setViewState({ type: 'browse-marketplace', targetMarketplace: name })
setViewState({ type: 'browse-marketplace', targetMarketplace: name });
}
} catch (err) {
const error = toError(err)
logError(error)
setError(error.message)
setProgressMessage('')
setLoading(false)
const error = toError(err);
logError(error);
setError(error.message);
setProgressMessage('');
setLoading(false);
if (cliMode) {
// In CLI mode, set result with error to trigger completion
setResult(`Error: ${error.message}`)
setResult(`Error: ${error.message}`);
} else {
setResult(null)
setResult(null);
}
}
}
};
// Auto-add if inputValue is provided
useEffect(() => {
if (inputValue && !hasAttemptedAutoAdd.current && !error && !result) {
hasAttemptedAutoAdd.current = true
void handleAdd()
hasAttemptedAutoAdd.current = true;
void handleAdd();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Only run once on mount
}, []); // Only run once on mount
return (
<Box flexDirection="column">
@@ -164,9 +154,7 @@ export function AddMarketplace({
{isLoading && (
<Box marginTop={1}>
<Spinner />
<Text>
{progressMessage || 'Adding marketplace to configuration…'}
</Text>
<Text>{progressMessage || 'Adding marketplace to configuration…'}</Text>
</Box>
)}
{error && (
@@ -184,15 +172,10 @@ export function AddMarketplace({
<Text dimColor italic>
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="add" />
<ConfigurableShortcutHint
action="confirm:no"
context="Settings"
fallback="Esc"
description="cancel"
/>
<ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="cancel" />
</Byline>
</Text>
</Box>
</Box>
)
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,140 +1,139 @@
import { getPluginErrorMessage, type PluginError } from '../../types/plugin.js'
import { getPluginErrorMessage, type PluginError } from '../../types/plugin.js';
export function formatErrorMessage(error: PluginError): string {
switch (error.type) {
case 'path-not-found':
return `${error.component} path not found: ${error.path}`
return `${error.component} path not found: ${error.path}`;
case 'git-auth-failed':
return `Git ${error.authType.toUpperCase()} authentication failed for ${error.gitUrl}`
return `Git ${error.authType.toUpperCase()} authentication failed for ${error.gitUrl}`;
case 'git-timeout':
return `Git ${error.operation} timed out for ${error.gitUrl}`
return `Git ${error.operation} timed out for ${error.gitUrl}`;
case 'network-error':
return `Network error accessing ${error.url}${error.details ? `: ${error.details}` : ''}`
return `Network error accessing ${error.url}${error.details ? `: ${error.details}` : ''}`;
case 'manifest-parse-error':
return `Failed to parse manifest at ${error.manifestPath}: ${error.parseError}`
return `Failed to parse manifest at ${error.manifestPath}: ${error.parseError}`;
case 'manifest-validation-error':
return `Invalid manifest at ${error.manifestPath}: ${error.validationErrors.join(', ')}`
return `Invalid manifest at ${error.manifestPath}: ${error.validationErrors.join(', ')}`;
case 'plugin-not-found':
return `Plugin "${error.pluginId}" not found in marketplace "${error.marketplace}"`
return `Plugin "${error.pluginId}" not found in marketplace "${error.marketplace}"`;
case 'marketplace-not-found':
return `Marketplace "${error.marketplace}" not found`
return `Marketplace "${error.marketplace}" not found`;
case 'marketplace-load-failed':
return `Failed to load marketplace "${error.marketplace}": ${error.reason}`
return `Failed to load marketplace "${error.marketplace}": ${error.reason}`;
case 'mcp-config-invalid':
return `Invalid MCP server config for "${error.serverName}": ${error.validationError}`
return `Invalid MCP server config for "${error.serverName}": ${error.validationError}`;
case 'mcp-server-suppressed-duplicate': {
const dup = error.duplicateOf.startsWith('plugin:')
? `server provided by plugin "${error.duplicateOf.split(':')[1] ?? '?'}"`
: `already-configured "${error.duplicateOf}"`
return `MCP server "${error.serverName}" skipped — same command/URL as ${dup}`
: `already-configured "${error.duplicateOf}"`;
return `MCP server "${error.serverName}" skipped — same command/URL as ${dup}`;
}
case 'hook-load-failed':
return `Failed to load hooks from ${error.hookPath}: ${error.reason}`
return `Failed to load hooks from ${error.hookPath}: ${error.reason}`;
case 'component-load-failed':
return `Failed to load ${error.component} from ${error.path}: ${error.reason}`
return `Failed to load ${error.component} from ${error.path}: ${error.reason}`;
case 'mcpb-download-failed':
return `Failed to download MCPB from ${error.url}: ${error.reason}`
return `Failed to download MCPB from ${error.url}: ${error.reason}`;
case 'mcpb-extract-failed':
return `Failed to extract MCPB ${error.mcpbPath}: ${error.reason}`
return `Failed to extract MCPB ${error.mcpbPath}: ${error.reason}`;
case 'mcpb-invalid-manifest':
return `MCPB manifest invalid at ${error.mcpbPath}: ${error.validationError}`
return `MCPB manifest invalid at ${error.mcpbPath}: ${error.validationError}`;
case 'marketplace-blocked-by-policy':
return error.blockedByBlocklist
? `Marketplace "${error.marketplace}" is blocked by enterprise policy`
: `Marketplace "${error.marketplace}" is not in the allowed marketplace list`
: `Marketplace "${error.marketplace}" is not in the allowed marketplace list`;
case 'dependency-unsatisfied':
return error.reason === 'not-enabled'
? `Dependency "${error.dependency}" is disabled`
: `Dependency "${error.dependency}" is not installed`
: `Dependency "${error.dependency}" is not installed`;
case 'lsp-config-invalid':
return `Invalid LSP server config for "${error.serverName}": ${error.validationError}`
return `Invalid LSP server config for "${error.serverName}": ${error.validationError}`;
case 'lsp-server-start-failed':
return `LSP server "${error.serverName}" failed to start: ${error.reason}`
return `LSP server "${error.serverName}" failed to start: ${error.reason}`;
case 'lsp-server-crashed':
return error.signal
? `LSP server "${error.serverName}" crashed with signal ${error.signal}`
: `LSP server "${error.serverName}" crashed with exit code ${error.exitCode ?? 'unknown'}`
: `LSP server "${error.serverName}" crashed with exit code ${error.exitCode ?? 'unknown'}`;
case 'lsp-request-timeout':
return `LSP server "${error.serverName}" timed out on ${error.method} after ${error.timeoutMs}ms`
return `LSP server "${error.serverName}" timed out on ${error.method} after ${error.timeoutMs}ms`;
case 'lsp-request-failed':
return `LSP server "${error.serverName}" ${error.method} failed: ${error.error}`
return `LSP server "${error.serverName}" ${error.method} failed: ${error.error}`;
case 'plugin-cache-miss':
return `Plugin "${error.plugin}" not cached at ${error.installPath}`
return `Plugin "${error.plugin}" not cached at ${error.installPath}`;
case 'generic-error':
return error.error
return error.error;
}
const _exhaustive: never = error
return getPluginErrorMessage(_exhaustive)
const _exhaustive: never = error;
return getPluginErrorMessage(_exhaustive);
}
export function getErrorGuidance(error: PluginError): string | null {
switch (error.type) {
case 'path-not-found':
return 'Check that the path in your manifest or marketplace config is correct'
return 'Check that the path in your manifest or marketplace config is correct';
case 'git-auth-failed':
return error.authType === 'ssh'
? 'Configure SSH keys or use HTTPS URL instead'
: 'Configure credentials or use SSH URL instead'
: 'Configure credentials or use SSH URL instead';
case 'git-timeout':
case 'network-error':
return 'Check your internet connection and try again'
return 'Check your internet connection and try again';
case 'manifest-parse-error':
return 'Check manifest file syntax in the plugin directory'
return 'Check manifest file syntax in the plugin directory';
case 'manifest-validation-error':
return 'Check manifest file follows the required schema'
return 'Check manifest file follows the required schema';
case 'plugin-not-found':
return `Plugin may not exist in marketplace "${error.marketplace}"`
return `Plugin may not exist in marketplace "${error.marketplace}"`;
case 'marketplace-not-found':
return error.availableMarketplaces.length > 0
? `Available marketplaces: ${error.availableMarketplaces.join(', ')}`
: 'Add the marketplace first using /plugin marketplace add'
: 'Add the marketplace first using /plugin marketplace add';
case 'mcp-config-invalid':
return 'Check MCP server configuration in .mcp.json or manifest'
return 'Check MCP server configuration in .mcp.json or manifest';
case 'mcp-server-suppressed-duplicate': {
// duplicateOf is "plugin:name:srv" when another plugin won dedup —
// users can't remove plugin-provided servers from their MCP config,
// so point them at the winning plugin instead.
if (error.duplicateOf.startsWith('plugin:')) {
const winningPlugin =
error.duplicateOf.split(':')[1] ?? 'the other plugin'
return `Disable plugin "${winningPlugin}" if you want this plugin's version instead`
const winningPlugin = error.duplicateOf.split(':')[1] ?? 'the other plugin';
return `Disable plugin "${winningPlugin}" if you want this plugin's version instead`;
}
return `Remove "${error.duplicateOf}" from your MCP config if you want the plugin's version instead`
return `Remove "${error.duplicateOf}" from your MCP config if you want the plugin's version instead`;
}
case 'hook-load-failed':
return 'Check hooks.json file syntax and structure'
return 'Check hooks.json file syntax and structure';
case 'component-load-failed':
return `Check ${error.component} directory structure and file permissions`
return `Check ${error.component} directory structure and file permissions`;
case 'mcpb-download-failed':
return 'Check your internet connection and URL accessibility'
return 'Check your internet connection and URL accessibility';
case 'mcpb-extract-failed':
return 'Verify the MCPB file is valid and not corrupted'
return 'Verify the MCPB file is valid and not corrupted';
case 'mcpb-invalid-manifest':
return 'Contact the plugin author about the invalid manifest'
return 'Contact the plugin author about the invalid manifest';
case 'marketplace-blocked-by-policy':
if (error.blockedByBlocklist) {
return 'This marketplace source is explicitly blocked by your administrator'
return 'This marketplace source is explicitly blocked by your administrator';
}
return error.allowedSources.length > 0
? `Allowed sources: ${error.allowedSources.join(', ')}`
: 'Contact your administrator to configure allowed marketplace sources'
: 'Contact your administrator to configure allowed marketplace sources';
case 'dependency-unsatisfied':
return error.reason === 'not-enabled'
? `Enable "${error.dependency}" or uninstall "${error.plugin}"`
: `Install "${error.dependency}" or uninstall "${error.plugin}"`
: `Install "${error.dependency}" or uninstall "${error.plugin}"`;
case 'lsp-config-invalid':
return 'Check LSP server configuration in the plugin manifest'
return 'Check LSP server configuration in the plugin manifest';
case 'lsp-server-start-failed':
case 'lsp-server-crashed':
case 'lsp-request-timeout':
case 'lsp-request-failed':
return 'Check LSP server logs with --debug for details'
return 'Check LSP server logs with --debug for details';
case 'plugin-cache-miss':
return 'Run /plugins to refresh the plugin cache'
return 'Run /plugins to refresh the plugin cache';
case 'marketplace-load-failed':
case 'generic-error':
return null
return null;
}
const _exhaustive: never = error
return null
const _exhaustive: never = error;
return null;
}

View File

@@ -1,17 +1,11 @@
import figures from 'figures'
import React, { useCallback, useState } from 'react'
import { Dialog } from '@anthropic/ink'
import figures from 'figures';
import React, { useCallback, useState } from 'react';
import { Dialog } from '@anthropic/ink';
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw text input for config dialog
import { Box, Text, useInput, stringWidth } from '@anthropic/ink'
import {
useKeybinding,
useKeybindings,
} from '../../keybindings/useKeybinding.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import type {
PluginOptionSchema,
PluginOptionValues,
} from '../../utils/plugins/pluginOptionsStorage.js'
import { Box, Text, useInput, stringWidth } from '@anthropic/ink';
import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
import type { PluginOptionSchema, PluginOptionValues } from '../../utils/plugins/pluginOptionsStorage.js';
/**
* Build the onSave payload from collected string inputs.
@@ -31,43 +25,39 @@ export function buildFinalValues(
configSchema: PluginOptionSchema,
initialValues: PluginOptionValues | undefined,
): PluginOptionValues {
const finalValues: PluginOptionValues = {}
const finalValues: PluginOptionValues = {};
for (const fieldKey of fields) {
const schema = configSchema[fieldKey]
const value = collected[fieldKey] ?? ''
const schema = configSchema[fieldKey];
const value = collected[fieldKey] ?? '';
if (
schema?.sensitive === true &&
value === '' &&
initialValues?.[fieldKey] !== undefined
) {
continue
if (schema?.sensitive === true && value === '' && initialValues?.[fieldKey] !== undefined) {
continue;
}
if (schema?.type === 'number') {
// Number('') returns 0, not NaN — omit blank number inputs so
// validateUserConfig's required check actually catches them.
if (value.trim() === '') continue
const num = Number(value)
finalValues[fieldKey] = Number.isNaN(num) ? value : num
if (value.trim() === '') continue;
const num = Number(value);
finalValues[fieldKey] = Number.isNaN(num) ? value : num;
} else if (schema?.type === 'boolean') {
finalValues[fieldKey] = isEnvTruthy(value)
finalValues[fieldKey] = isEnvTruthy(value);
} else {
finalValues[fieldKey] = value
finalValues[fieldKey] = value;
}
}
return finalValues
return finalValues;
}
type Props = {
title: string
subtitle: string
configSchema: PluginOptionSchema
title: string;
subtitle: string;
configSchema: PluginOptionSchema;
/** Pre-fill fields when reconfiguring. Sensitive fields are not prepopulated. */
initialValues?: PluginOptionValues
onSave: (config: PluginOptionValues) => void
onCancel: () => void
}
initialValues?: PluginOptionValues;
onSave: (config: PluginOptionValues) => void;
onCancel: () => void;
};
export function PluginOptionsDialog({
title,
@@ -77,68 +67,56 @@ export function PluginOptionsDialog({
onSave,
onCancel,
}: Props): React.ReactNode {
const fields = Object.keys(configSchema)
const fields = Object.keys(configSchema);
// Prepopulate from initialValues but skip sensitive fields — we don't
// want to echo secrets back into the text buffer.
const initialFor = useCallback(
(key: string): string => {
if (configSchema[key]?.sensitive === true) return ''
const v = initialValues?.[key]
return v === undefined ? '' : String(v)
if (configSchema[key]?.sensitive === true) return '';
const v = initialValues?.[key];
return v === undefined ? '' : String(v);
},
[configSchema, initialValues],
)
);
const [currentFieldIndex, setCurrentFieldIndex] = useState(0)
const [values, setValues] = useState<Record<string, string>>({})
const [currentInput, setCurrentInput] = useState(() =>
fields[0] ? initialFor(fields[0]) : '',
)
const [currentFieldIndex, setCurrentFieldIndex] = useState(0);
const [values, setValues] = useState<Record<string, string>>({});
const [currentInput, setCurrentInput] = useState(() => (fields[0] ? initialFor(fields[0]) : ''));
const currentField = fields[currentFieldIndex]
const fieldSchema = currentField ? configSchema[currentField] : null
const currentField = fields[currentFieldIndex];
const fieldSchema = currentField ? configSchema[currentField] : null;
// Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input).
// isCancelActive={false} on Dialog keeps its own confirm:no out of the way.
useKeybinding('confirm:no', onCancel, { context: 'Settings' })
useKeybinding('confirm:no', onCancel, { context: 'Settings' });
// Tab to next field
const handleNextField = useCallback(() => {
if (currentFieldIndex < fields.length - 1 && currentField) {
setValues(prev => ({ ...prev, [currentField]: currentInput }))
setCurrentFieldIndex(prev => prev + 1)
const nextKey = fields[currentFieldIndex + 1]
setCurrentInput(nextKey ? initialFor(nextKey) : '')
setValues(prev => ({ ...prev, [currentField]: currentInput }));
setCurrentFieldIndex(prev => prev + 1);
const nextKey = fields[currentFieldIndex + 1];
setCurrentInput(nextKey ? initialFor(nextKey) : '');
}
}, [currentFieldIndex, fields, currentField, currentInput, initialFor])
}, [currentFieldIndex, fields, currentField, currentInput, initialFor]);
// Enter to save current field and move to next, or save all if last
const handleConfirm = useCallback(() => {
if (!currentField) return
if (!currentField) return;
const newValues = { ...values, [currentField]: currentInput }
const newValues = { ...values, [currentField]: currentInput };
if (currentFieldIndex === fields.length - 1) {
onSave(buildFinalValues(fields, newValues, configSchema, initialValues))
onSave(buildFinalValues(fields, newValues, configSchema, initialValues));
} else {
// Move to next field
setValues(newValues)
setCurrentFieldIndex(prev => prev + 1)
const nextKey = fields[currentFieldIndex + 1]
setCurrentInput(nextKey ? initialFor(nextKey) : '')
setValues(newValues);
setCurrentFieldIndex(prev => prev + 1);
const nextKey = fields[currentFieldIndex + 1];
setCurrentInput(nextKey ? initialFor(nextKey) : '');
}
}, [
currentField,
values,
currentInput,
currentFieldIndex,
fields,
configSchema,
onSave,
initialFor,
initialValues,
])
}, [currentField, values, currentInput, currentFieldIndex, fields, configSchema, onSave, initialFor, initialValues]);
useKeybindings(
{
@@ -146,47 +124,38 @@ export function PluginOptionsDialog({
'confirm:yes': handleConfirm,
},
{ context: 'Confirmation' },
)
);
// Character input handling (backspace, typing)
useInput((char, key) => {
// Backspace
if (key.backspace || key.delete) {
setCurrentInput(prev => prev.slice(0, -1))
return
setCurrentInput(prev => prev.slice(0, -1));
return;
}
// Regular character input
if (char && !key.ctrl && !key.meta && !key.tab && !key.return) {
setCurrentInput(prev => prev + char)
setCurrentInput(prev => prev + char);
}
})
});
if (!fieldSchema || !currentField) {
return null
return null;
}
const isSensitive = fieldSchema.sensitive === true
const isRequired = fieldSchema.required === true
const displayValue = isSensitive
? '*'.repeat(stringWidth(currentInput))
: currentInput
const isSensitive = fieldSchema.sensitive === true;
const isRequired = fieldSchema.required === true;
const displayValue = isSensitive ? '*'.repeat(stringWidth(currentInput)) : currentInput;
return (
<Dialog
title={title}
subtitle={subtitle}
onCancel={onCancel}
isCancelActive={false}
>
<Dialog title={title} subtitle={subtitle} onCancel={onCancel} isCancelActive={false}>
<Box flexDirection="column">
<Text bold={true}>
{fieldSchema.title || currentField}
{isRequired && <Text color="error"> *</Text>}
</Text>
{fieldSchema.description && (
<Text dimColor={true}>{fieldSchema.description}</Text>
)}
{fieldSchema.description && <Text dimColor={true}>{fieldSchema.description}</Text>}
<Box marginTop={1}>
<Text>{figures.pointerSmall} </Text>
@@ -200,14 +169,10 @@ export function PluginOptionsDialog({
Field {currentFieldIndex + 1} of {fields.length}
</Text>
{currentFieldIndex < fields.length - 1 && (
<Text dimColor={true}>
Tab: Next field · Enter: Save and continue
</Text>
)}
{currentFieldIndex === fields.length - 1 && (
<Text dimColor={true}>Enter: Save configuration</Text>
<Text dimColor={true}>Tab: Next field · Enter: Save and continue</Text>
)}
{currentFieldIndex === fields.length - 1 && <Text dimColor={true}>Enter: Save configuration</Text>}
</Box>
</Dialog>
)
);
}

View File

@@ -7,26 +7,20 @@
* onDone('skipped') immediately if nothing needs filling.
*/
import * as React from 'react'
import type { LoadedPlugin } from '../../types/plugin.js'
import { errorMessage } from '../../utils/errors.js'
import {
loadMcpServerUserConfig,
saveMcpServerUserConfig,
} from '../../utils/plugins/mcpbHandler.js'
import {
getUnconfiguredChannels,
type UnconfiguredChannel,
} from '../../utils/plugins/mcpPluginIntegration.js'
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
import * as React from 'react';
import type { LoadedPlugin } from '../../types/plugin.js';
import { errorMessage } from '../../utils/errors.js';
import { loadMcpServerUserConfig, saveMcpServerUserConfig } from '../../utils/plugins/mcpbHandler.js';
import { getUnconfiguredChannels, type UnconfiguredChannel } from '../../utils/plugins/mcpPluginIntegration.js';
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js';
import {
getUnconfiguredOptions,
loadPluginOptions,
type PluginOptionSchema,
type PluginOptionValues,
savePluginOptions,
} from '../../utils/plugins/pluginOptionsStorage.js'
import { PluginOptionsDialog } from './PluginOptionsDialog.js'
} from '../../utils/plugins/pluginOptionsStorage.js';
import { PluginOptionsDialog } from './PluginOptionsDialog.js';
/**
* Post-install lookup: return the LoadedPlugin for the just-installed
@@ -36,13 +30,9 @@ import { PluginOptionsDialog } from './PluginOptionsDialog.js'
*
* Install should have cleared caches already; loadAllPlugins reads fresh.
*/
export async function findPluginOptionsTarget(
pluginId: string,
): Promise<LoadedPlugin | undefined> {
const { enabled, disabled } = await loadAllPlugins()
return [...enabled, ...disabled].find(
p => p.repository === pluginId || p.source === pluginId,
)
export async function findPluginOptionsTarget(pluginId: string): Promise<LoadedPlugin | undefined> {
const { enabled, disabled } = await loadAllPlugins();
return [...enabled, ...disabled].find(p => p.repository === pluginId || p.source === pluginId);
}
/**
@@ -50,39 +40,35 @@ export async function findPluginOptionsTarget(
* collapse to this shape — the only difference is which save function runs.
*/
type ConfigStep = {
key: string
title: string
subtitle: string
schema: PluginOptionSchema
key: string;
title: string;
subtitle: string;
schema: PluginOptionSchema;
/** Returns any already-saved values so PluginOptionsDialog can pre-fill and
* skip unchanged sensitive fields on reconfigure. */
load: () => PluginOptionValues | undefined
save: (values: PluginOptionValues) => void
}
load: () => PluginOptionValues | undefined;
save: (values: PluginOptionValues) => void;
};
type Props = {
plugin: LoadedPlugin
plugin: LoadedPlugin;
/** `name@marketplace` — the savePluginOptions / saveMcpServerUserConfig key. */
pluginId: string
pluginId: string;
/**
* `configured` = user filled all fields. `skipped` = nothing needed
* configuring, or user hit cancel. `error` = save threw.
*/
onDone: (outcome: 'configured' | 'skipped' | 'error', detail?: string) => void
}
onDone: (outcome: 'configured' | 'skipped' | 'error', detail?: string) => void;
};
export function PluginOptionsFlow({
plugin,
pluginId,
onDone,
}: Props): React.ReactNode {
export function PluginOptionsFlow({ plugin, pluginId, onDone }: Props): React.ReactNode {
// Build the step list once at mount. Re-calling after a save would drop the
// item we just configured.
const [steps] = React.useState<ConfigStep[]>(() => {
const result: ConfigStep[] = []
const result: ConfigStep[] = [];
// Top-level manifest.userConfig
const unconfigured = getUnconfiguredOptions(plugin)
const unconfigured = getUnconfiguredOptions(plugin);
if (Object.keys(unconfigured).length > 0) {
result.push({
key: 'top-level',
@@ -90,68 +76,60 @@ export function PluginOptionsFlow({
subtitle: 'Plugin options',
schema: unconfigured,
load: () => loadPluginOptions(pluginId),
save: values =>
savePluginOptions(pluginId, values, plugin.manifest.userConfig!),
})
save: values => savePluginOptions(pluginId, values, plugin.manifest.userConfig!),
});
}
// Per-channel userConfig (assistant-mode channels)
const channels: UnconfiguredChannel[] = getUnconfiguredChannels(plugin)
const channels: UnconfiguredChannel[] = getUnconfiguredChannels(plugin);
for (const channel of channels) {
result.push({
key: `channel:${channel.server}`,
title: `Configure ${channel.displayName}`,
subtitle: `Plugin: ${plugin.name}`,
schema: channel.configSchema,
load: () =>
loadMcpServerUserConfig(pluginId, channel.server) ?? undefined,
save: values =>
saveMcpServerUserConfig(
pluginId,
channel.server,
values,
channel.configSchema,
),
})
load: () => loadMcpServerUserConfig(pluginId, channel.server) ?? undefined,
save: values => saveMcpServerUserConfig(pluginId, channel.server, values, channel.configSchema),
});
}
return result
})
return result;
});
const [index, setIndex] = React.useState(0)
const [index, setIndex] = React.useState(0);
// Latest-ref: lets the effect close over the current onDone without
// re-running when the parent re-renders.
const onDoneRef = React.useRef(onDone)
onDoneRef.current = onDone
const onDoneRef = React.useRef(onDone);
onDoneRef.current = onDone;
// Nothing to configure → tell the caller and render nothing. Effect,
// not inline call: calling setState in the parent during our render
// is a React rules-of-hooks violation.
React.useEffect(() => {
if (steps.length === 0) {
onDoneRef.current('skipped')
onDoneRef.current('skipped');
}
}, [steps.length])
}, [steps.length]);
if (steps.length === 0) {
return null
return null;
}
const current = steps[index]!
const current = steps[index]!;
function handleSave(values: PluginOptionValues): void {
try {
current.save(values)
current.save(values);
} catch (err) {
onDone('error', errorMessage(err))
return
onDone('error', errorMessage(err));
return;
}
const next = index + 1
const next = index + 1;
if (next < steps.length) {
setIndex(next)
setIndex(next);
} else {
onDone('configured')
onDone('configured');
}
}
@@ -168,5 +146,5 @@ export function PluginOptionsFlow({
onSave={handleSave}
onCancel={() => onDone('skipped')}
/>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,19 @@
import figures from 'figures'
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import { getPluginTrustMessage } from '../../utils/plugins/marketplaceHelpers.js'
import figures from 'figures';
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import { getPluginTrustMessage } from '../../utils/plugins/marketplaceHelpers.js';
export function PluginTrustWarning(): React.ReactNode {
const customMessage = getPluginTrustMessage()
const customMessage = getPluginTrustMessage();
return (
<Box marginBottom={1}>
<Text color="claude">{figures.warning} </Text>
<Text dimColor italic>
Make sure you trust a plugin before installing, updating, or using it.
Anthropic does not control what MCP servers, files, or other software
are included in plugins and cannot verify that they will work as
intended or that they won&apos;t change. See each plugin&apos;s homepage
for more information.{customMessage ? ` ${customMessage}` : ''}
Make sure you trust a plugin before installing, updating, or using it. Anthropic does not control what MCP
servers, files, or other software are included in plugins and cannot verify that they will work as intended or
that they won&apos;t change. See each plugin&apos;s homepage for more information.
{customMessage ? ` ${customMessage}` : ''}
</Text>
</Box>
)
);
}

View File

@@ -1,46 +1,40 @@
import figures from 'figures'
import * as React from 'react'
import { Box, color, Text, useTheme } from '@anthropic/ink'
import { plural } from '../../utils/stringUtils.js'
import type { UnifiedInstalledItem } from './unifiedTypes.js'
import figures from 'figures';
import * as React from 'react';
import { Box, color, Text, useTheme } from '@anthropic/ink';
import { plural } from '../../utils/stringUtils.js';
import type { UnifiedInstalledItem } from './unifiedTypes.js';
type Props = {
item: UnifiedInstalledItem
isSelected: boolean
}
item: UnifiedInstalledItem;
isSelected: boolean;
};
export function UnifiedInstalledCell({
item,
isSelected,
}: Props): React.ReactNode {
const [theme] = useTheme()
export function UnifiedInstalledCell({ item, isSelected }: Props): React.ReactNode {
const [theme] = useTheme();
if (item.type === 'plugin') {
// Status icon and text
let statusIcon: string
let statusText: string
let statusIcon: string;
let statusText: string;
// Show pending toggle status if set, otherwise show current status
if (item.pendingToggle) {
statusIcon = color('suggestion', theme)(figures.arrowRight)
statusText =
item.pendingToggle === 'will-enable' ? 'will enable' : 'will disable'
statusIcon = color('suggestion', theme)(figures.arrowRight);
statusText = item.pendingToggle === 'will-enable' ? 'will enable' : 'will disable';
} else if (item.errorCount > 0) {
statusIcon = color('error', theme)(figures.cross)
statusText = `${item.errorCount} ${plural(item.errorCount, 'error')}`
statusIcon = color('error', theme)(figures.cross);
statusText = `${item.errorCount} ${plural(item.errorCount, 'error')}`;
} else if (!item.isEnabled) {
statusIcon = color('inactive', theme)(figures.radioOff)
statusText = 'disabled'
statusIcon = color('inactive', theme)(figures.radioOff);
statusText = 'disabled';
} else {
statusIcon = color('success', theme)(figures.tick)
statusText = 'enabled'
statusIcon = color('success', theme)(figures.tick);
statusText = 'enabled';
}
return (
<Box>
<Text color={isSelected ? 'suggestion' : undefined}>
{isSelected ? `${figures.pointer} ` : ' '}
</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
<Text dimColor={!isSelected}>
{' '}
@@ -50,17 +44,15 @@ export function UnifiedInstalledCell({
<Text dimColor={!isSelected}> · {statusIcon} </Text>
<Text dimColor={!isSelected}>{statusText}</Text>
</Box>
)
);
}
if (item.type === 'flagged-plugin') {
const statusIcon = color('warning', theme)(figures.warning)
const statusIcon = color('warning', theme)(figures.warning);
return (
<Box>
<Text color={isSelected ? 'suggestion' : undefined}>
{isSelected ? `${figures.pointer} ` : ' '}
</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
<Text dimColor={!isSelected}>
{' '}
@@ -70,18 +62,16 @@ export function UnifiedInstalledCell({
<Text dimColor={!isSelected}> · {statusIcon} </Text>
<Text dimColor={!isSelected}>removed</Text>
</Box>
)
);
}
if (item.type === 'failed-plugin') {
const statusIcon = color('error', theme)(figures.cross)
const statusText = `failed to load · ${item.errorCount} ${plural(item.errorCount, 'error')}`
const statusIcon = color('error', theme)(figures.cross);
const statusText = `failed to load · ${item.errorCount} ${plural(item.errorCount, 'error')}`;
return (
<Box>
<Text color={isSelected ? 'suggestion' : undefined}>
{isSelected ? `${figures.pointer} ` : ' '}
</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
<Text dimColor={!isSelected}>
{' '}
@@ -91,37 +81,35 @@ export function UnifiedInstalledCell({
<Text dimColor={!isSelected}> · {statusIcon} </Text>
<Text dimColor={!isSelected}>{statusText}</Text>
</Box>
)
);
}
// MCP server
let statusIcon: string
let statusText: string
let statusIcon: string;
let statusText: string;
if (item.status === 'connected') {
statusIcon = color('success', theme)(figures.tick)
statusText = 'connected'
statusIcon = color('success', theme)(figures.tick);
statusText = 'connected';
} else if (item.status === 'disabled') {
statusIcon = color('inactive', theme)(figures.radioOff)
statusText = 'disabled'
statusIcon = color('inactive', theme)(figures.radioOff);
statusText = 'disabled';
} else if (item.status === 'pending') {
statusIcon = color('inactive', theme)(figures.radioOff)
statusText = 'connecting…'
statusIcon = color('inactive', theme)(figures.radioOff);
statusText = 'connecting…';
} else if (item.status === 'needs-auth') {
statusIcon = color('warning', theme)(figures.triangleUpOutline)
statusText = 'Enter to auth'
statusIcon = color('warning', theme)(figures.triangleUpOutline);
statusText = 'Enter to auth';
} else {
statusIcon = color('error', theme)(figures.cross)
statusText = 'failed'
statusIcon = color('error', theme)(figures.cross);
statusText = 'failed';
}
// Indented MCPs (child of a plugin)
if (item.indented) {
return (
<Box>
<Text color={isSelected ? 'suggestion' : undefined}>
{isSelected ? `${figures.pointer} ` : ' '}
</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
<Text dimColor={!isSelected}> </Text>
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
<Text dimColor={!isSelected}>
@@ -131,14 +119,12 @@ export function UnifiedInstalledCell({
<Text dimColor={!isSelected}> · {statusIcon} </Text>
<Text dimColor={!isSelected}>{statusText}</Text>
</Box>
)
);
}
return (
<Box>
<Text color={isSelected ? 'suggestion' : undefined}>
{isSelected ? `${figures.pointer} ` : ' '}
</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
<Text dimColor={!isSelected}>
{' '}
@@ -147,5 +133,5 @@ export function UnifiedInstalledCell({
<Text dimColor={!isSelected}> · {statusIcon} </Text>
<Text dimColor={!isSelected}>{statusText}</Text>
</Box>
)
);
}

View File

@@ -1,16 +1,16 @@
import figures from 'figures'
import * as React from 'react'
import { useEffect } from 'react'
import { Box, Text } from '@anthropic/ink'
import { errorMessage } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { validateManifest } from '../../utils/plugins/validatePlugin.js'
import { plural } from '../../utils/stringUtils.js'
import figures from 'figures';
import * as React from 'react';
import { useEffect } from 'react';
import { Box, Text } from '@anthropic/ink';
import { errorMessage } from '../../utils/errors.js';
import { logError } from '../../utils/log.js';
import { validateManifest } from '../../utils/plugins/validatePlugin.js';
import { plural } from '../../utils/stringUtils.js';
type Props = {
onComplete: (result?: string) => void
path?: string
}
onComplete: (result?: string) => void;
path?: string;
};
export function ValidatePlugin({ onComplete, path }: Props): React.ReactNode {
useEffect(() => {
@@ -28,76 +28,74 @@ export function ValidatePlugin({ onComplete, path }: Props): React.ReactNode {
'or .claude-plugin/plugin.json (prefers marketplace if both exist).\n\n' +
'Or from the command line:\n' +
' claude plugin validate <path>',
)
return
);
return;
}
try {
const result = await validateManifest(path)
const result = await validateManifest(path);
let output = ''
let output = '';
// Add header
output += `Validating ${result.fileType} manifest: ${result.filePath}\n\n`
output += `Validating ${result.fileType} manifest: ${result.filePath}\n\n`;
// Show errors
if (result.errors.length > 0) {
output += `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n\n`
output += `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n\n`;
result.errors.forEach(error => {
output += ` ${figures.pointer} ${error.path}: ${error.message}\n`
})
output += ` ${figures.pointer} ${error.path}: ${error.message}\n`;
});
output += '\n'
output += '\n';
}
// Show warnings
if (result.warnings.length > 0) {
output += `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n\n`
output += `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n\n`;
result.warnings.forEach(warning => {
output += ` ${figures.pointer} ${warning.path}: ${warning.message}\n`
})
output += ` ${figures.pointer} ${warning.path}: ${warning.message}\n`;
});
output += '\n'
output += '\n';
}
// Show success or failure
if (result.success) {
if (result.warnings.length > 0) {
output += `${figures.tick} Validation passed with warnings\n`
output += `${figures.tick} Validation passed with warnings\n`;
} else {
output += `${figures.tick} Validation passed\n`
output += `${figures.tick} Validation passed\n`;
}
// Exit with code 0 (success)
process.exitCode = 0
process.exitCode = 0;
} else {
output += `${figures.cross} Validation failed\n`
output += `${figures.cross} Validation failed\n`;
// Exit with code 1 (validation failure)
process.exitCode = 1
process.exitCode = 1;
}
onComplete(output)
onComplete(output);
} catch (error) {
// Exit with code 2 (unexpected error)
process.exitCode = 2
process.exitCode = 2;
logError(error)
logError(error);
onComplete(
`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`,
)
onComplete(`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`);
}
}
void runValidation()
}, [onComplete, path])
void runValidation();
}, [onComplete, path]);
return (
<Box flexDirection="column">
<Text>Running validation...</Text>
</Box>
)
);
}

View File

@@ -1,147 +1,149 @@
import { describe, expect, test } from "bun:test";
import { parsePluginArgs } from "../parseArgs";
import { describe, expect, test } from 'bun:test'
import { parsePluginArgs } from '../parseArgs'
describe("parsePluginArgs", () => {
describe('parsePluginArgs', () => {
// No args
test("returns { type: 'menu' } for undefined", () => {
expect(parsePluginArgs(undefined)).toEqual({ type: "menu" });
});
expect(parsePluginArgs(undefined)).toEqual({ type: 'menu' })
})
test("returns { type: 'menu' } for empty string", () => {
expect(parsePluginArgs("")).toEqual({ type: "menu" });
});
expect(parsePluginArgs('')).toEqual({ type: 'menu' })
})
test("returns { type: 'menu' } for whitespace only", () => {
expect(parsePluginArgs(" ")).toEqual({ type: "menu" });
});
expect(parsePluginArgs(' ')).toEqual({ type: 'menu' })
})
// Help
test("returns { type: 'help' } for 'help'", () => {
expect(parsePluginArgs("help")).toEqual({ type: "help" });
});
expect(parsePluginArgs('help')).toEqual({ type: 'help' })
})
test("returns { type: 'help' } for '--help'", () => {
expect(parsePluginArgs("--help")).toEqual({ type: "help" });
});
expect(parsePluginArgs('--help')).toEqual({ type: 'help' })
})
test("returns { type: 'help' } for '-h'", () => {
expect(parsePluginArgs("-h")).toEqual({ type: "help" });
});
expect(parsePluginArgs('-h')).toEqual({ type: 'help' })
})
// Install
test("parses 'install my-plugin' -> { type: 'install', plugin: 'my-plugin' }", () => {
expect(parsePluginArgs("install my-plugin")).toEqual({
type: "install",
plugin: "my-plugin",
});
});
expect(parsePluginArgs('install my-plugin')).toEqual({
type: 'install',
plugin: 'my-plugin',
})
})
test("parses 'install my-plugin@github' with marketplace", () => {
expect(parsePluginArgs("install my-plugin@github")).toEqual({
type: "install",
plugin: "my-plugin",
marketplace: "github",
});
});
expect(parsePluginArgs('install my-plugin@github')).toEqual({
type: 'install',
plugin: 'my-plugin',
marketplace: 'github',
})
})
test("parses 'install https://github.com/...' as URL marketplace", () => {
expect(parsePluginArgs("install https://github.com/plugins/my-plugin")).toEqual({
type: "install",
marketplace: "https://github.com/plugins/my-plugin",
});
});
expect(
parsePluginArgs('install https://github.com/plugins/my-plugin'),
).toEqual({
type: 'install',
marketplace: 'https://github.com/plugins/my-plugin',
})
})
test("parses 'i plugin' as install shorthand", () => {
expect(parsePluginArgs("i plugin")).toEqual({
type: "install",
plugin: "plugin",
});
});
expect(parsePluginArgs('i plugin')).toEqual({
type: 'install',
plugin: 'plugin',
})
})
test("install without target returns type only", () => {
expect(parsePluginArgs("install")).toEqual({ type: "install" });
});
test('install without target returns type only', () => {
expect(parsePluginArgs('install')).toEqual({ type: 'install' })
})
// Uninstall
test("returns { type: 'uninstall', plugin: '...' }", () => {
expect(parsePluginArgs("uninstall my-plugin")).toEqual({
type: "uninstall",
plugin: "my-plugin",
});
});
expect(parsePluginArgs('uninstall my-plugin')).toEqual({
type: 'uninstall',
plugin: 'my-plugin',
})
})
// Enable/disable
test("returns { type: 'enable', plugin: '...' }", () => {
expect(parsePluginArgs("enable my-plugin")).toEqual({
type: "enable",
plugin: "my-plugin",
});
});
expect(parsePluginArgs('enable my-plugin')).toEqual({
type: 'enable',
plugin: 'my-plugin',
})
})
test("returns { type: 'disable', plugin: '...' }", () => {
expect(parsePluginArgs("disable my-plugin")).toEqual({
type: "disable",
plugin: "my-plugin",
});
});
expect(parsePluginArgs('disable my-plugin')).toEqual({
type: 'disable',
plugin: 'my-plugin',
})
})
// Validate
test("returns { type: 'validate', path: '...' }", () => {
expect(parsePluginArgs("validate /path/to/plugin")).toEqual({
type: "validate",
path: "/path/to/plugin",
});
});
expect(parsePluginArgs('validate /path/to/plugin')).toEqual({
type: 'validate',
path: '/path/to/plugin',
})
})
// Manage
test("returns { type: 'manage' }", () => {
expect(parsePluginArgs("manage")).toEqual({ type: "manage" });
});
expect(parsePluginArgs('manage')).toEqual({ type: 'manage' })
})
// Marketplace
test("parses 'marketplace add ...'", () => {
expect(parsePluginArgs("marketplace add https://example.com")).toEqual({
type: "marketplace",
action: "add",
target: "https://example.com",
});
});
expect(parsePluginArgs('marketplace add https://example.com')).toEqual({
type: 'marketplace',
action: 'add',
target: 'https://example.com',
})
})
test("parses 'marketplace remove ...'", () => {
expect(parsePluginArgs("marketplace remove my-source")).toEqual({
type: "marketplace",
action: "remove",
target: "my-source",
});
});
expect(parsePluginArgs('marketplace remove my-source')).toEqual({
type: 'marketplace',
action: 'remove',
target: 'my-source',
})
})
test("parses 'marketplace list'", () => {
expect(parsePluginArgs("marketplace list")).toEqual({
type: "marketplace",
action: "list",
});
});
expect(parsePluginArgs('marketplace list')).toEqual({
type: 'marketplace',
action: 'list',
})
})
test("parses 'market' as alias for 'marketplace'", () => {
expect(parsePluginArgs("market list")).toEqual({
type: "marketplace",
action: "list",
});
});
expect(parsePluginArgs('market list')).toEqual({
type: 'marketplace',
action: 'list',
})
})
// Boundary
test("handles extra whitespace", () => {
expect(parsePluginArgs(" install my-plugin ")).toEqual({
type: "install",
plugin: "my-plugin",
});
});
test('handles extra whitespace', () => {
expect(parsePluginArgs(' install my-plugin ')).toEqual({
type: 'install',
plugin: 'my-plugin',
})
})
test("handles unknown subcommand gracefully", () => {
expect(parsePluginArgs("foobar")).toEqual({ type: "menu" });
});
test('handles unknown subcommand gracefully', () => {
expect(parsePluginArgs('foobar')).toEqual({ type: 'menu' })
})
test("marketplace without action returns type only", () => {
expect(parsePluginArgs("marketplace")).toEqual({ type: "marketplace" });
});
});
test('marketplace without action returns type only', () => {
expect(parsePluginArgs('marketplace')).toEqual({ type: 'marketplace' })
})
})

View File

@@ -1,4 +1,4 @@
import type { Command } from '../../commands.js'
import type { Command } from '../../commands.js';
const plugin = {
type: 'local-jsx',
@@ -7,6 +7,6 @@ const plugin = {
description: 'Manage Claude Code plugins',
immediate: true,
load: () => import('./plugin.js'),
} satisfies Command
} satisfies Command;
export default plugin
export default plugin;

View File

@@ -1,11 +1,7 @@
import * as React from 'react'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { PluginSettings } from './PluginSettings.js'
import * as React from 'react';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { PluginSettings } from './PluginSettings.js';
export async function call(
onDone: LocalJSXCommandOnDone,
_context: unknown,
args?: string,
): Promise<React.ReactNode> {
return <PluginSettings onComplete={onDone} args={args} />
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
return <PluginSettings onComplete={onDone} args={args} />;
}

View File

@@ -4,28 +4,28 @@
* Used by both DiscoverPlugins and BrowseMarketplace components.
*/
import * as React from 'react'
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'
import { Box, Byline, Text } from '@anthropic/ink'
import type { PluginMarketplaceEntry } from '../../utils/plugins/schemas.js'
import * as React from 'react';
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
import { Box, Byline, Text } from '@anthropic/ink';
import type { PluginMarketplaceEntry } from '../../utils/plugins/schemas.js';
/**
* Represents a plugin available for installation from a marketplace
*/
export type InstallablePlugin = {
entry: PluginMarketplaceEntry
marketplaceName: string
pluginId: string
isInstalled: boolean
}
entry: PluginMarketplaceEntry;
marketplaceName: string;
pluginId: string;
isInstalled: boolean;
};
/**
* Menu option for plugin details view
*/
export type PluginDetailsMenuOption = {
label: string
action: string
}
label: string;
action: string;
};
/**
* Extract GitHub repo info from a plugin's source
@@ -35,17 +35,13 @@ export function extractGitHubRepo(plugin: InstallablePlugin): string | null {
plugin.entry.source &&
typeof plugin.entry.source === 'object' &&
'source' in plugin.entry.source &&
plugin.entry.source.source === 'github'
plugin.entry.source.source === 'github';
if (
isGitHub &&
typeof plugin.entry.source === 'object' &&
'repo' in plugin.entry.source
) {
return plugin.entry.source.repo
if (isGitHub && typeof plugin.entry.source === 'object' && 'repo' in plugin.entry.source) {
return plugin.entry.source.repo;
}
return null
return null;
}
/**
@@ -65,25 +61,21 @@ export function buildPluginDetailsMenuOptions(
label: 'Install for you, in this repo only (local scope)',
action: 'install-local',
},
]
];
if (hasHomepage) {
options.push({ label: 'Open homepage', action: 'homepage' })
options.push({ label: 'Open homepage', action: 'homepage' });
}
if (githubRepo) {
options.push({ label: 'View on GitHub', action: 'github' })
options.push({ label: 'View on GitHub', action: 'github' });
}
options.push({ label: 'Back to plugin list', action: 'back' })
return options
options.push({ label: 'Back to plugin list', action: 'back' });
return options;
}
/**
* Key hint component for plugin selection screens
*/
export function PluginSelectionKeyHint({
hasSelection,
}: {
hasSelection: boolean
}): React.ReactNode {
export function PluginSelectionKeyHint({ hasSelection }: { hasSelection: boolean }): React.ReactNode {
return (
<Box marginTop={1}>
<Text dimColor italic>
@@ -97,26 +89,11 @@ export function PluginSelectionKeyHint({
bold
/>
)}
<ConfigurableShortcutHint
action="plugin:toggle"
context="Plugin"
fallback="Space"
description="toggle"
/>
<ConfigurableShortcutHint
action="select:accept"
context="Select"
fallback="Enter"
description="details"
/>
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="back"
/>
<ConfigurableShortcutHint action="plugin:toggle" context="Plugin" fallback="Space" description="toggle" />
<ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="details" />
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />
</Byline>
</Text>
</Box>
)
);
}

View File

@@ -1,3 +1,3 @@
// Auto-generated stub — replace with real implementation
export type ViewState = any;
export type PluginSettingsProps = any;
export type ViewState = any
export type PluginSettingsProps = any

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type UnifiedInstalledItem = any;
export type UnifiedInstalledItem = any