mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
Improve KTX agent setup guidance (#137)
* feat(cli): clarify MCP start output * feat(cli): improve agent setup guidance * docs: update agent client setup guidance
This commit is contained in:
parent
b507ff171d
commit
1331e573dd
10 changed files with 804 additions and 173 deletions
|
|
@ -9,7 +9,6 @@ import {
|
|||
} from '@ktx/context/project';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { bold, dim, green } from './io/symbols.js';
|
||||
import { withMultiselectNavigation } from './prompt-navigation.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
|
|
@ -31,6 +30,7 @@ export interface KtxSetupAgentsArgs {
|
|||
scope: KtxAgentScope;
|
||||
mode: KtxAgentInstallMode;
|
||||
skipAgents: boolean;
|
||||
showNextActions?: boolean;
|
||||
}
|
||||
|
||||
export type KtxSetupAgentsResult =
|
||||
|
|
@ -38,6 +38,7 @@ export type KtxSetupAgentsResult =
|
|||
status: 'ready';
|
||||
projectDir: string;
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
|
||||
nextActions?: string;
|
||||
}
|
||||
| { status: 'skipped'; projectDir: string }
|
||||
| { status: 'back'; projectDir: string }
|
||||
|
|
@ -69,6 +70,8 @@ interface KtxMcpClientInstallResult {
|
|||
notices: string[];
|
||||
}
|
||||
|
||||
const MCP_DAEMON_REQUIRED_NOTICE = 'mcp-daemon-required';
|
||||
|
||||
interface KtxCliLauncher {
|
||||
command: string;
|
||||
args: string[];
|
||||
|
|
@ -257,7 +260,7 @@ async function installMcpClientConfig(input: {
|
|||
|
||||
const endpoint = await resolveMcpEndpoint(input.projectDir);
|
||||
if (!endpoint.running) {
|
||||
notices.push('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
notices.push(MCP_DAEMON_REQUIRED_NOTICE);
|
||||
}
|
||||
|
||||
if (input.target === 'claude-code') {
|
||||
|
|
@ -269,15 +272,15 @@ async function installMcpClientConfig(input: {
|
|||
await writeJsonKey(config.path, config.jsonPath, cursorMcpEntry(endpoint));
|
||||
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
|
||||
} else if (input.target === 'codex') {
|
||||
snippets.push(`Codex MCP snippet for ~/.codex/config.toml:\n${codexSnippet(endpoint)}`);
|
||||
snippets.push(`Add this Codex MCP snippet to ~/.codex/config.toml:\n${codexSnippet(endpoint)}`);
|
||||
} else if (input.target === 'opencode') {
|
||||
const path =
|
||||
input.scope === 'global'
|
||||
? '~/.config/opencode/opencode.json'
|
||||
: relative(input.projectDir, join(input.projectDir, 'opencode.json'));
|
||||
snippets.push(`opencode MCP snippet for ${path}:\n${opencodeSnippet(endpoint)}`);
|
||||
snippets.push(`Add this OpenCode MCP snippet to ${path}:\n${opencodeSnippet(endpoint)}`);
|
||||
} else if (input.target === 'universal') {
|
||||
snippets.push(universalMcpSnippet(endpoint));
|
||||
snippets.push(`Use this universal MCP endpoint with unsupported MCP clients:\n${universalMcpSnippet(endpoint)}`);
|
||||
}
|
||||
|
||||
return { entries, snippets, notices };
|
||||
|
|
@ -504,7 +507,7 @@ function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boo
|
|||
return [
|
||||
'# KTX Claude Plugin',
|
||||
'',
|
||||
'Install this plugin ZIP from Claude Desktop to load the KTX analytics skill.',
|
||||
'This package is generated by KTX setup. Claude Desktop loads KTX through the registered `claude_desktop_config.json` entry after restart; no manual plugin install step is required.',
|
||||
'',
|
||||
`KTX project: \`${input.projectDir}\``,
|
||||
'',
|
||||
|
|
@ -515,7 +518,7 @@ function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boo
|
|||
'',
|
||||
'The KTX MCP server is registered separately in `claude_desktop_config.json` by `ktx setup` and runs as a local stdio child of Claude Desktop — no daemon to start.',
|
||||
'',
|
||||
'If this checkout or project directory moves, rerun `ktx setup --agents` and reinstall the regenerated plugin.',
|
||||
'If this checkout or project directory moves, rerun `ktx setup --agents` and restart Claude Desktop.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
|
@ -719,17 +722,8 @@ const targetDisplayNames: Record<KtxAgentTarget, string> = {
|
|||
universal: 'Universal .agents',
|
||||
};
|
||||
|
||||
const fileEntryLabels: Record<KtxAgentTarget, string> = {
|
||||
'claude-code': 'Skill installed',
|
||||
'claude-desktop': 'Skill installed',
|
||||
codex: 'Skill installed',
|
||||
cursor: 'Rule installed',
|
||||
opencode: 'Command installed',
|
||||
universal: 'Skill installed',
|
||||
};
|
||||
|
||||
function mcpEntryLabel(entry: Extract<InstallEntry, { kind: 'json-key' }>): string {
|
||||
return `MCP config installed — connects client agents to KTX MCP tools (${entry.jsonPath.join('.')})`;
|
||||
export function targetDisplayName(target: string): string {
|
||||
return Object.hasOwn(targetDisplayNames, target) ? targetDisplayNames[target as KtxAgentTarget] : target;
|
||||
}
|
||||
|
||||
function targetSupportsGlobalScope(target: KtxAgentTarget): boolean {
|
||||
|
|
@ -740,11 +734,58 @@ function effectiveInstallScope(target: KtxAgentTarget, requestedScope: KtxAgentS
|
|||
return target === 'claude-desktop' ? 'global' : requestedScope;
|
||||
}
|
||||
|
||||
function scopeDisplayName(scope: KtxAgentScope): string {
|
||||
if (scope === 'project') return 'Project scope';
|
||||
if (scope === 'global') return 'Global scope';
|
||||
return 'Local scope';
|
||||
}
|
||||
|
||||
function targetUsesHttpMcpDaemon(target: KtxAgentTarget): boolean {
|
||||
return target !== 'claude-desktop';
|
||||
}
|
||||
|
||||
function manualMcpConfigInstruction(target: KtxAgentTarget, scope: KtxAgentScope): string {
|
||||
if (target === 'codex') {
|
||||
return 'Add the snippet shown below to ~/.codex/config.toml.';
|
||||
}
|
||||
if (target === 'opencode') {
|
||||
return scope === 'global'
|
||||
? 'Add the snippet shown below to ~/.config/opencode/opencode.json.'
|
||||
: 'Add the snippet shown below to opencode.json.';
|
||||
}
|
||||
if (target === 'universal') {
|
||||
return 'Use the printed endpoint with unsupported MCP clients.';
|
||||
}
|
||||
return 'Add the printed snippet manually.';
|
||||
}
|
||||
|
||||
function guidanceInstallLine(target: KtxAgentTarget): string {
|
||||
if (target === 'codex') return 'Codex guidance installed';
|
||||
if (target === 'cursor') return 'Cursor rules installed';
|
||||
if (target === 'opencode') return 'OpenCode commands installed';
|
||||
if (target === 'universal') return '.agents guidance installed';
|
||||
if (target === 'claude-desktop') return 'Claude Desktop skills bundled';
|
||||
return 'Agent guidance installed';
|
||||
}
|
||||
|
||||
function hasEntryRole(entries: InstallEntry[], role: Extract<InstallEntry, { kind: 'file' }>['role']): boolean {
|
||||
return entries.some((entry) => entry.kind === 'file' && entry.role === role);
|
||||
}
|
||||
|
||||
function hasAdminCliEntries(entries: InstallEntry[]): boolean {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.kind === 'file' &&
|
||||
(entry.role === 'skill' || entry.role === 'rule' || entry.role === undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatInstallSummary(
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>,
|
||||
entries: InstallEntry[],
|
||||
projectDir: string,
|
||||
): string {
|
||||
const resolvedProjectDir = resolve(projectDir);
|
||||
const entriesByTarget = new Map<KtxAgentTarget, InstallEntry[]>();
|
||||
for (const install of installs) {
|
||||
const plannedFilePaths = new Set(
|
||||
|
|
@ -766,51 +807,193 @@ export function formatInstallSummary(
|
|||
);
|
||||
}
|
||||
|
||||
const fileHints: Record<string, string> = {
|
||||
skill: 'teaches admin agents which KTX CLI commands to run',
|
||||
rule: 'tells admin agents when to use KTX CLI',
|
||||
'analytics-skill': 'teaches your agent the KTX MCP analytics workflow',
|
||||
'claude-plugin': 'bundles KTX skills for Claude Desktop (MCP server is registered in claude_desktop_config.json)',
|
||||
launcher: 'runs the local KTX CLI with an available Node.js for Claude Desktop',
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
const lines: string[] = ['KTX project', ` ${resolvedProjectDir}`, '', 'Installed agents'];
|
||||
for (const install of installs) {
|
||||
const targetEntries = entriesByTarget.get(install.target) ?? [];
|
||||
lines.push(` ${targetDisplayNames[install.target]}`);
|
||||
for (const entry of targetEntries) {
|
||||
if (entry.kind === 'file') {
|
||||
const isRule = entry.role === 'rule' || (!entry.role && fileEntryLabels[install.target] === 'Rule installed');
|
||||
const label =
|
||||
entry.role === 'analytics-skill'
|
||||
? 'Analytics skill installed'
|
||||
: entry.role === 'claude-plugin'
|
||||
? 'Claude plugin generated'
|
||||
: entry.role === 'launcher'
|
||||
? 'Launcher installed'
|
||||
: isRule
|
||||
? 'Rule installed'
|
||||
: fileEntryLabels[install.target];
|
||||
const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? '';
|
||||
lines.push(` + ${label} — ${hint}`);
|
||||
if (entry.role !== 'claude-plugin') {
|
||||
const displayPath =
|
||||
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
|
||||
lines.push(` ${displayPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of mcpEntriesByTarget
|
||||
const mcpEntry = mcpEntriesByTarget
|
||||
.get(install.target)
|
||||
?.filter((entry): entry is Extract<InstallEntry, { kind: 'json-key' }> => entry.kind === 'json-key') ?? []) {
|
||||
const displayPath = install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
|
||||
lines.push(` + ${mcpEntryLabel(entry)}`);
|
||||
lines.push(` ${displayPath}`);
|
||||
?.find((entry): entry is Extract<InstallEntry, { kind: 'json-key' }> => entry.kind === 'json-key');
|
||||
lines.push('', ` ${targetDisplayName(install.target)}`);
|
||||
if (mcpEntry) {
|
||||
lines.push(` ${scopeDisplayName(install.scope)}`);
|
||||
lines.push(` ${mcpEntry.path}`);
|
||||
} else if (install.target !== 'claude-desktop') {
|
||||
lines.push(' MCP config');
|
||||
lines.push(` ${manualMcpConfigInstruction(install.target, install.scope)}`);
|
||||
}
|
||||
if (targetUsesHttpMcpDaemon(install.target)) {
|
||||
lines.push(' Requires MCP to be started');
|
||||
}
|
||||
const hasAnalytics = hasEntryRole(targetEntries, 'analytics-skill');
|
||||
const hasAdmin = hasAdminCliEntries(targetEntries);
|
||||
const hasPlugin = hasEntryRole(targetEntries, 'claude-plugin');
|
||||
if (install.target === 'claude-code') {
|
||||
if (hasAnalytics) {
|
||||
lines.push(' Analytics skill installed');
|
||||
}
|
||||
if (hasAdmin) {
|
||||
lines.push(' Admin CLI skill installed');
|
||||
}
|
||||
} else if (hasAnalytics || hasAdmin || hasPlugin) {
|
||||
lines.push(` ${guidanceInstallLine(install.target)}`);
|
||||
}
|
||||
if (hasEntryRole(targetEntries, 'launcher')) {
|
||||
lines.push(' Starts KTX over stdio from Claude Desktop');
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function humanList(values: string[]): string {
|
||||
if (values.length <= 2) {
|
||||
return values.join(' and ');
|
||||
}
|
||||
return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`;
|
||||
}
|
||||
|
||||
function pushBlankLine(lines: string[]): void {
|
||||
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingBlankLines(lines: string[]): void {
|
||||
while (lines[lines.length - 1] === '') {
|
||||
lines.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function manualActionFromSnippet(snippet: string): {
|
||||
title: string;
|
||||
instruction: string;
|
||||
marker: 'PASTE' | 'USE';
|
||||
body: string[];
|
||||
} {
|
||||
const [label = '', ...body] = snippet.split('\n');
|
||||
const codexPrefix = 'Add this Codex MCP snippet to ~/.codex/config.toml:';
|
||||
if (label === codexPrefix) {
|
||||
return {
|
||||
title: 'Configure Codex',
|
||||
instruction: 'Open ~/.codex/config.toml, then paste this block:',
|
||||
marker: 'PASTE',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
const opencodeMatch = label.match(/^Add this OpenCode MCP snippet to (.+):$/);
|
||||
if (opencodeMatch) {
|
||||
return {
|
||||
title: 'Configure OpenCode',
|
||||
instruction: `Open ${opencodeMatch[1]}, then paste this block:`,
|
||||
marker: 'PASTE',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
if (label === 'Use this universal MCP endpoint with unsupported MCP clients:') {
|
||||
return {
|
||||
title: 'Configure unsupported MCP clients',
|
||||
instruction: 'Use this endpoint when setting up unsupported MCP clients:',
|
||||
marker: 'USE',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Configure MCP client',
|
||||
instruction: label,
|
||||
marker: 'PASTE',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function formatAgentNextActions(input: {
|
||||
projectDir: string;
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
|
||||
notices: string[];
|
||||
snippets: string[];
|
||||
}): string {
|
||||
const projectDir = resolve(input.projectDir);
|
||||
const lines: string[] = [];
|
||||
let step = 1;
|
||||
|
||||
for (const snippet of input.snippets) {
|
||||
const action = manualActionFromSnippet(snippet);
|
||||
lines.push(`${step}. ${action.title}`);
|
||||
lines.push(` ${action.instruction}`);
|
||||
if (action.body.length > 0) {
|
||||
lines.push('', ` ${action.marker}:`);
|
||||
}
|
||||
for (const line of action.body) {
|
||||
lines.push(` ${line}`);
|
||||
}
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
|
||||
const httpTargets = input.installs
|
||||
.filter((install) => targetUsesHttpMcpDaemon(install.target))
|
||||
.map((install) => targetDisplayName(install.target));
|
||||
if (input.notices.length > 0 && httpTargets.length > 0) {
|
||||
lines.push(`${step}. Start MCP`);
|
||||
lines.push(` Run this command before using ${humanList(httpTargets)}:`);
|
||||
lines.push('');
|
||||
lines.push(' RUN:');
|
||||
lines.push(` ktx mcp start --project-dir ${projectDir}`);
|
||||
lines.push('');
|
||||
lines.push(' If you need to stop MCP later:');
|
||||
lines.push(` ktx mcp stop --project-dir ${projectDir}`);
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
|
||||
const claudeCodeInstall = input.installs.find((install) => install.target === 'claude-code');
|
||||
if (claudeCodeInstall) {
|
||||
lines.push(`${step}. Open Claude Code`);
|
||||
if (claudeCodeInstall.scope === 'project') {
|
||||
lines.push(' Open Claude Code from the KTX project directory:');
|
||||
lines.push('');
|
||||
lines.push(' RUN:');
|
||||
lines.push(` cd ${shellScriptQuote(projectDir)}`);
|
||||
lines.push(' claude');
|
||||
} else {
|
||||
lines.push(' RUN:');
|
||||
lines.push(' claude');
|
||||
}
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
|
||||
const cursorInstall = input.installs.find((install) => install.target === 'cursor');
|
||||
if (cursorInstall) {
|
||||
lines.push(`${step}. Open Cursor`);
|
||||
if (cursorInstall.scope === 'project') {
|
||||
lines.push(' Open Cursor from the KTX project directory:');
|
||||
lines.push('');
|
||||
lines.push(' OPEN:');
|
||||
lines.push(` ${projectDir}`);
|
||||
} else {
|
||||
lines.push(' Open Cursor.');
|
||||
}
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
|
||||
if (input.installs.some((install) => install.target === 'claude-desktop')) {
|
||||
lines.push(`${step}. Restart Claude Desktop`);
|
||||
lines.push(' Claude Desktop loads KTX after restart.');
|
||||
pushBlankLine(lines);
|
||||
step += 1;
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
lines.push('Open your configured agent and ask a data question.');
|
||||
}
|
||||
|
||||
trimTrailingBlankLines(lines);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function installTarget(input: {
|
||||
projectDir: string;
|
||||
target: KtxAgentTarget;
|
||||
|
|
@ -870,10 +1053,18 @@ export async function runKtxSetupAgentsStep(
|
|||
args.inputMode === 'disabled'
|
||||
? args.mode
|
||||
: ((await prompts.select({
|
||||
message: 'How should client agents connect to this KTX project?',
|
||||
message: 'What should agents be allowed to do with this KTX project?',
|
||||
options: [
|
||||
{ value: 'mcp', label: 'MCP tools + analytics skill' },
|
||||
{ value: 'mcp-cli', label: 'MCP tools + analytics skill + admin CLI skill' },
|
||||
{
|
||||
value: 'mcp',
|
||||
label: 'Ask data questions with KTX MCP',
|
||||
hint: 'Installs the MCP connection and analytics workflow skill. Best for normal use.',
|
||||
},
|
||||
{
|
||||
value: 'mcp-cli',
|
||||
label: 'Ask data questions + manage KTX with CLI commands',
|
||||
hint: 'Adds an admin CLI skill so agents can run ktx status, sl, wiki, and setup commands.',
|
||||
},
|
||||
],
|
||||
})) as KtxAgentInstallMode | 'back');
|
||||
if (mode === 'back') return { status: 'skipped', projectDir: args.projectDir };
|
||||
|
|
@ -908,10 +1099,18 @@ export async function runKtxSetupAgentsStep(
|
|||
scopeTargets.length > 0 &&
|
||||
scopeTargets.every(targetSupportsGlobalScope)
|
||||
? ((await prompts.select({
|
||||
message: 'Where should KTX install supported agent config?',
|
||||
message: `Where should KTX install supported agent config?\n\nKTX project: ${resolve(args.projectDir)}`,
|
||||
options: [
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'global', label: 'Global' },
|
||||
{
|
||||
value: 'project',
|
||||
label: 'Project scope (KTX project directory)',
|
||||
hint: 'Only agents opened from this KTX project path load the project-scoped config.',
|
||||
},
|
||||
{
|
||||
value: 'global',
|
||||
label: 'Global scope (user config)',
|
||||
hint: 'Agents can load this KTX project from any working directory.',
|
||||
},
|
||||
],
|
||||
})) as KtxAgentScope | 'back')
|
||||
: args.scope;
|
||||
|
|
@ -921,7 +1120,6 @@ export async function runKtxSetupAgentsStep(
|
|||
const entries: InstallEntry[] = [];
|
||||
const snippets: string[] = [];
|
||||
const notices = new Set<string>();
|
||||
let claudeDesktopTutorial: string | undefined;
|
||||
try {
|
||||
for (const install of installs) {
|
||||
const targetEntries = await installTarget({ projectDir: args.projectDir, ...install });
|
||||
|
|
@ -934,25 +1132,6 @@ export async function runKtxSetupAgentsStep(
|
|||
entries.push(...mcpResult.entries);
|
||||
for (const snippet of mcpResult.snippets) snippets.push(snippet);
|
||||
for (const notice of mcpResult.notices) notices.add(notice);
|
||||
if (install.target === 'claude-desktop') {
|
||||
const pluginEntry = targetEntries.find(
|
||||
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
|
||||
entry.kind === 'file' && entry.role === 'claude-plugin',
|
||||
);
|
||||
const pluginPath = pluginEntry?.path ?? '';
|
||||
const configPath = claudeDesktopConfigPath().path;
|
||||
claudeDesktopTutorial = [
|
||||
`${green('✓')} ${bold('KTX MCP server registered')}`,
|
||||
` ${dim(configPath)}`,
|
||||
'',
|
||||
bold('1. Restart Claude Desktop'),
|
||||
' Quit and reopen so it picks up the new MCP server.',
|
||||
'',
|
||||
bold('2. Install the KTX plugin'),
|
||||
' Open Claude Desktop → Settings → Plugins and install from file:',
|
||||
` 📦 ${dim(pluginPath)}`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
await writeManifest(
|
||||
args.projectDir,
|
||||
|
|
@ -965,18 +1144,16 @@ export async function runKtxSetupAgentsStep(
|
|||
'Agent integration complete',
|
||||
io,
|
||||
);
|
||||
if (claudeDesktopTutorial) {
|
||||
setupUi.note(claudeDesktopTutorial, 'Finish Claude Desktop setup', io, {
|
||||
format: (line) => line,
|
||||
});
|
||||
const nextActions = formatAgentNextActions({
|
||||
projectDir: args.projectDir,
|
||||
installs,
|
||||
notices: [...notices],
|
||||
snippets,
|
||||
});
|
||||
if (args.showNextActions !== false) {
|
||||
setupUi.note(nextActions, 'Required before using agents', io, { format: (line) => line });
|
||||
}
|
||||
const nextStepBlocks: string[] = [];
|
||||
for (const notice of notices) nextStepBlocks.push(notice);
|
||||
for (const snippet of snippets) nextStepBlocks.push(snippet);
|
||||
if (nextStepBlocks.length > 0) {
|
||||
setupUi.note(nextStepBlocks.join('\n\n'), 'Next steps', io, { format: bold });
|
||||
}
|
||||
return { status: 'ready', projectDir: args.projectDir, installs };
|
||||
return { status: 'ready', projectDir: args.projectDir, installs, nextActions };
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue