feat: audit console TUI, system prompt management, and CLI improvements

Audit Console Phase 1: tool_call_trace emission from mcplocal router,
session_bind/rbac_decision event kinds, GET /audit/sessions endpoint,
full Ink TUI with session sidebar, event timeline, and detail view
(mcpctl console --audit).

System prompts: move 6 hardcoded LLM prompts to mcpctl-system project
with extensible ResourceRuleRegistry validation framework, template
variable enforcement ({{maxTokens}}, {{pageCount}}), and delete-resets-
to-default behavior. All consumers fetch via SystemPromptFetcher with
hardcoded fallbacks.

CLI: -p shorthand for --project across get/create/delete/config commands,
console auto-scroll improvements, shell completions regenerated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-03 23:50:54 +00:00
parent 89f869f460
commit 5d859ca7d8
42 changed files with 1932 additions and 77 deletions

View File

@@ -90,7 +90,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
const cmd = config
.command(name)
.description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge')
.option('--project <name>', 'Project name')
.option('-p, --project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring')
.option('--stdout', 'Print to stdout instead of writing a file')

View File

@@ -0,0 +1,458 @@
/**
* AuditConsoleApp — TUI for browsing audit events from mcpd.
*
* Shows sessions in a sidebar and events in a timeline.
* Polls mcpd periodically for new data.
*/
import { useState, useEffect, useCallback } from 'react';
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
import type { AuditSession, AuditEvent, AuditConsoleState } from './audit-types.js';
import { EVENT_KIND_COLORS, EVENT_KIND_LABELS } from './audit-types.js';
import http from 'node:http';
const POLL_INTERVAL_MS = 3_000;
const MAX_EVENTS = 500;
// ── HTTP helpers ──
function fetchJson<T>(url: string, token?: string): Promise<T> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const headers: Record<string, string> = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const req = http.get({ hostname: parsed.hostname, port: parsed.port, path: parsed.pathname + parsed.search, headers, timeout: 5000 }, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
res.on('end', () => {
try {
resolve(JSON.parse(data) as T);
} catch {
reject(new Error(`Invalid JSON from ${url}`));
}
});
});
req.on('error', (err) => reject(err));
req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
});
}
// ── Format helpers ──
function formatTime(ts: string): string {
const d = new Date(ts);
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function trunc(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
}
function formatPayload(payload: Record<string, unknown>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(payload)) {
if (v === null || v === undefined) continue;
if (typeof v === 'string') {
parts.push(`${k}=${trunc(v, 30)}`);
} else if (typeof v === 'number' || typeof v === 'boolean') {
parts.push(`${k}=${String(v)}`);
}
}
return parts.join(' ');
}
function formatDetailPayload(payload: Record<string, unknown>): string[] {
const lines: string[] = [];
for (const [k, v] of Object.entries(payload)) {
if (v === null || v === undefined) {
lines.push(` ${k}: null`);
} else if (typeof v === 'object') {
lines.push(` ${k}: ${JSON.stringify(v, null, 2).split('\n').join('\n ')}`);
} else {
lines.push(` ${k}: ${String(v)}`);
}
}
return lines;
}
// ── Session Sidebar ──
function AuditSidebar({ sessions, selectedIdx, projectFilter }: { sessions: AuditSession[]; selectedIdx: number; projectFilter: string | null }) {
return (
<Box flexDirection="column" width={30} borderStyle="single" borderColor="gray" paddingX={1}>
<Text bold>Sessions</Text>
<Text dimColor>{projectFilter ? `project: ${projectFilter}` : 'all projects'}</Text>
<Text> </Text>
<Text color={selectedIdx === -1 ? 'cyan' : undefined} bold={selectedIdx === -1}>
{selectedIdx === -1 ? '\u25B8 ' : ' '}All ({sessions.reduce((s, x) => s + x.eventCount, 0)} events)
</Text>
{sessions.map((s, i) => {
const isSel = i === selectedIdx;
return (
<Text key={s.sessionId} color={isSel ? 'cyan' : undefined} bold={isSel} wrap="truncate">
{isSel ? '\u25B8 ' : ' '}{trunc(s.sessionId.slice(0, 12), 12)} <Text dimColor>{s.projectName} ({s.eventCount})</Text>
</Text>
);
})}
{sessions.length === 0 && <Text dimColor> No sessions</Text>}
</Box>
);
}
// ── Event Timeline ──
function AuditTimeline({ events, height, focusedIdx }: { events: AuditEvent[]; height: number; focusedIdx: number }) {
const maxVisible = Math.max(1, height - 2);
let startIdx: number;
if (focusedIdx >= 0) {
startIdx = Math.max(0, Math.min(focusedIdx - Math.floor(maxVisible / 2), events.length - maxVisible));
} else {
startIdx = Math.max(0, events.length - maxVisible);
}
const visible = events.slice(startIdx, startIdx + maxVisible);
return (
<Box flexDirection="column" flexGrow={1} paddingLeft={1}>
<Text bold>
Events <Text dimColor>({events.length}{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ' \u00B7 following'})</Text>
</Text>
{visible.length === 0 && (
<Box marginTop={1}>
<Text dimColor>{' No audit events yet\u2026'}</Text>
</Box>
)}
{visible.map((event, vi) => {
const absIdx = startIdx + vi;
const isFocused = absIdx === focusedIdx;
const kindColor = EVENT_KIND_COLORS[event.eventKind] ?? 'white';
const kindLabel = EVENT_KIND_LABELS[event.eventKind] ?? event.eventKind.toUpperCase();
const verified = event.verified ? '\u2713' : '\u2717';
const verifiedColor = event.verified ? 'green' : 'red';
const summary = formatPayload(event.payload);
return (
<Text key={event.id} wrap="truncate">
<Text color={isFocused ? 'cyan' : undefined}>{isFocused ? '\u25B8' : ' '}</Text>
<Text dimColor>{formatTime(event.timestamp)} </Text>
<Text color={verifiedColor}>{verified}</Text>
<Text> </Text>
<Text color={kindColor} bold>{trunc(kindLabel, 9).padEnd(9)}</Text>
{event.serverName && <Text color="gray"> [{trunc(event.serverName, 14)}]</Text>}
<Text dimColor> {trunc(summary, 60)}</Text>
</Text>
);
})}
</Box>
);
}
// ── Detail View ──
function AuditDetail({ event, scrollOffset, height }: { event: AuditEvent; scrollOffset: number; height: number }) {
const kindColor = EVENT_KIND_COLORS[event.eventKind] ?? 'white';
const kindLabel = EVENT_KIND_LABELS[event.eventKind] ?? event.eventKind;
const lines = [
`Kind: ${kindLabel}`,
`Session: ${event.sessionId}`,
`Project: ${event.projectName}`,
`Source: ${event.source}`,
`Verified: ${event.verified ? 'yes' : 'no'}`,
`Server: ${event.serverName ?? '-'}`,
`Time: ${new Date(event.timestamp).toLocaleString()}`,
`ID: ${event.id}`,
'',
'Payload:',
...formatDetailPayload(event.payload),
];
const maxVisible = Math.max(1, height - 2);
const visible = lines.slice(scrollOffset, scrollOffset + maxVisible);
return (
<Box flexDirection="column" flexGrow={1} paddingLeft={1}>
<Text bold color={kindColor}>
{kindLabel} Detail <Text dimColor>(line {scrollOffset + 1}/{lines.length})</Text>
</Text>
{visible.map((line, i) => (
<Text key={i} wrap="truncate">{line}</Text>
))}
</Box>
);
}
// ── Main App ──
interface AuditAppProps {
mcpdUrl: string;
token?: string;
projectFilter?: string;
}
function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
const { exit } = useApp();
const { stdout } = useStdout();
const [state, setState] = useState<AuditConsoleState>({
phase: 'loading',
error: null,
sessions: [],
selectedSessionIdx: -1,
showSidebar: true,
events: [],
focusedEventIdx: -1,
totalEvents: 0,
detailEvent: null,
detailScrollOffset: 0,
projectFilter: projectFilter ?? null,
kindFilter: null,
});
// Fetch sessions
const fetchSessions = useCallback(async () => {
try {
const params = new URLSearchParams();
if (state.projectFilter) params.set('projectName', state.projectFilter);
params.set('limit', '50');
const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`;
const data = await fetchJson<{ sessions: AuditSession[]; total: number }>(url, token);
setState((prev) => ({ ...prev, sessions: data.sessions, phase: 'ready' }));
} catch (err) {
setState((prev) => ({ ...prev, phase: 'error', error: err instanceof Error ? err.message : String(err) }));
}
}, [mcpdUrl, token, state.projectFilter]);
// Fetch events
const fetchEvents = useCallback(async () => {
try {
const params = new URLSearchParams();
const selectedSession = state.selectedSessionIdx >= 0 ? state.sessions[state.selectedSessionIdx] : undefined;
if (selectedSession) {
params.set('sessionId', selectedSession.sessionId);
} else if (state.projectFilter) {
params.set('projectName', state.projectFilter);
}
if (state.kindFilter) params.set('eventKind', state.kindFilter);
params.set('limit', String(MAX_EVENTS));
const url = `${mcpdUrl}/api/v1/audit/events?${params.toString()}`;
const data = await fetchJson<{ events: AuditEvent[]; total: number }>(url, token);
// API returns newest first — reverse for timeline display
setState((prev) => ({ ...prev, events: data.events.reverse(), totalEvents: data.total }));
} catch {
// Non-fatal — keep existing events
}
}, [mcpdUrl, token, state.selectedSessionIdx, state.sessions, state.projectFilter, state.kindFilter]);
// Initial load + polling
useEffect(() => {
void fetchSessions();
void fetchEvents();
const timer = setInterval(() => {
void fetchSessions();
void fetchEvents();
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
}, [fetchSessions, fetchEvents]);
// Keyboard input
useInput((input, key) => {
// Quit
if (input === 'q') {
exit();
return;
}
// Toggle sidebar
if (key.escape) {
setState((prev) => ({ ...prev, showSidebar: !prev.showSidebar }));
return;
}
// Detail view navigation
if (state.detailEvent) {
if (input === 'q' || key.escape) {
setState((prev) => ({ ...prev, detailEvent: null, detailScrollOffset: 0 }));
return;
}
if (key.downArrow) {
setState((prev) => ({ ...prev, detailScrollOffset: prev.detailScrollOffset + 1 }));
return;
}
if (key.upArrow) {
setState((prev) => ({ ...prev, detailScrollOffset: Math.max(0, prev.detailScrollOffset - 1) }));
return;
}
if (key.pageDown) {
const pageSize = Math.max(1, Math.floor(stdout.rows * 0.5));
setState((prev) => ({ ...prev, detailScrollOffset: prev.detailScrollOffset + pageSize }));
return;
}
if (key.pageUp) {
const pageSize = Math.max(1, Math.floor(stdout.rows * 0.5));
setState((prev) => ({ ...prev, detailScrollOffset: Math.max(0, prev.detailScrollOffset - pageSize) }));
return;
}
return;
}
// Sidebar mode: Tab toggles focus between sidebar and timeline
if (key.tab && state.showSidebar) {
// Tab cycles: session list focus ↔ timeline focus
// Use selectedSessionIdx >= -2 as "sidebar focused" indicator
// Actually, let's use a simpler approach: Shift+Tab or 's' focuses sidebar
return;
}
// Session navigation (when sidebar visible)
if (state.showSidebar && (input === 'S' || input === 's')) {
// s/S cycles through sessions
setState((prev) => {
const max = prev.sessions.length - 1;
const next = prev.selectedSessionIdx >= max ? -1 : prev.selectedSessionIdx + 1;
return { ...prev, selectedSessionIdx: next, focusedEventIdx: -1 };
});
return;
}
// Kind filter: k cycles through event kinds
if (input === 'k') {
const kinds = [null, 'tool_call_trace', 'gate_decision', 'pipeline_execution', 'stage_execution', 'prompt_delivery', 'session_bind'];
setState((prev) => {
const currentIdx = kinds.indexOf(prev.kindFilter);
const nextIdx = (currentIdx + 1) % kinds.length;
return { ...prev, kindFilter: kinds[nextIdx]!, focusedEventIdx: -1 };
});
return;
}
// Auto-scroll resume
if (input === 'a') {
setState((prev) => ({ ...prev, focusedEventIdx: -1 }));
return;
}
// Enter: detail view
if (key.return) {
setState((prev) => {
const idx = prev.focusedEventIdx === -1 ? prev.events.length - 1 : prev.focusedEventIdx;
const event = prev.events[idx];
if (!event) return prev;
return { ...prev, detailEvent: event, detailScrollOffset: 0 };
});
return;
}
// Timeline navigation
if (key.downArrow) {
setState((prev) => {
if (prev.focusedEventIdx === -1) return prev;
return { ...prev, focusedEventIdx: Math.min(prev.events.length - 1, prev.focusedEventIdx + 1) };
});
return;
}
if (key.upArrow) {
setState((prev) => {
if (prev.focusedEventIdx === -1) {
return prev.events.length > 0 ? { ...prev, focusedEventIdx: prev.events.length - 1 } : prev;
}
return { ...prev, focusedEventIdx: prev.focusedEventIdx <= 0 ? -1 : prev.focusedEventIdx - 1 };
});
return;
}
if (key.pageDown) {
const pageSize = Math.max(1, stdout.rows - 8);
setState((prev) => {
if (prev.focusedEventIdx === -1) return prev;
return { ...prev, focusedEventIdx: Math.min(prev.events.length - 1, prev.focusedEventIdx + pageSize) };
});
return;
}
if (key.pageUp) {
const pageSize = Math.max(1, stdout.rows - 8);
setState((prev) => {
const current = prev.focusedEventIdx === -1 ? prev.events.length - 1 : prev.focusedEventIdx;
return { ...prev, focusedEventIdx: Math.max(0, current - pageSize) };
});
return;
}
});
const height = stdout.rows - 3; // header + footer
if (state.phase === 'loading') {
return (
<Box flexDirection="column">
<Text bold color="cyan">Audit Console</Text>
<Text dimColor>Connecting to mcpd{'\u2026'}</Text>
</Box>
);
}
if (state.phase === 'error') {
return (
<Box flexDirection="column">
<Text bold color="red">Audit Console Error</Text>
<Text color="red">{state.error}</Text>
<Text dimColor>Check mcpd is running and accessible at {mcpdUrl}</Text>
</Box>
);
}
// Detail view
if (state.detailEvent) {
return (
<Box flexDirection="column" height={stdout.rows}>
<Box flexGrow={1}>
<AuditDetail event={state.detailEvent} scrollOffset={state.detailScrollOffset} height={height} />
</Box>
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor>[{'\u2191\u2193'}] scroll [PgUp/Dn] page [Esc] back [q] quit</Text>
</Box>
</Box>
);
}
// Main view
return (
<Box flexDirection="column" height={stdout.rows}>
{/* Header */}
<Box paddingX={1}>
<Text bold color="cyan">Audit Console</Text>
<Text dimColor> {state.totalEvents} total events</Text>
{state.kindFilter && <Text color="yellow"> filter: {EVENT_KIND_LABELS[state.kindFilter] ?? state.kindFilter}</Text>}
</Box>
{/* Body */}
<Box flexGrow={1}>
{state.showSidebar && (
<AuditSidebar sessions={state.sessions} selectedIdx={state.selectedSessionIdx} projectFilter={state.projectFilter} />
)}
<AuditTimeline events={state.events} height={height} focusedIdx={state.focusedEventIdx} />
</Box>
{/* Footer */}
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor>
{state.focusedEventIdx === -1
? `[\u2191] nav [PgUp] page [s] session [k] kind [Enter] detail [Esc] sidebar [q] quit`
: `[\u2191\u2193] nav [PgUp/Dn] page [a] follow [s] session [k] kind [Enter] detail [Esc] sidebar [q] quit`}
</Text>
</Box>
</Box>
);
}
// ── Render entry point ──
export interface AuditRenderOptions {
mcpdUrl: string;
token?: string;
projectFilter?: string;
}
export async function renderAuditConsole(opts: AuditRenderOptions): Promise<void> {
const instance = render(
<AuditApp mcpdUrl={opts.mcpdUrl} token={opts.token} projectFilter={opts.projectFilter} />,
);
await instance.waitUntilExit();
}

View File

@@ -0,0 +1,69 @@
/**
* Types for the audit console — views audit events from mcpd.
*/
export interface AuditSession {
sessionId: string;
projectName: string;
firstSeen: string;
lastSeen: string;
eventCount: number;
eventKinds: string[];
}
export interface AuditEvent {
id: string;
timestamp: string;
sessionId: string;
projectName: string;
eventKind: string;
source: string;
verified: boolean;
serverName: string | null;
correlationId: string | null;
parentEventId: string | null;
payload: Record<string, unknown>;
}
export interface AuditConsoleState {
phase: 'loading' | 'ready' | 'error';
error: string | null;
// Sessions
sessions: AuditSession[];
selectedSessionIdx: number; // -1 = all sessions, 0+ = specific session
showSidebar: boolean;
// Events
events: AuditEvent[];
focusedEventIdx: number; // -1 = auto-scroll
totalEvents: number;
// Detail view
detailEvent: AuditEvent | null;
detailScrollOffset: number;
// Filters
projectFilter: string | null;
kindFilter: string | null;
}
export const EVENT_KIND_COLORS: Record<string, string> = {
'pipeline_execution': 'blue',
'stage_execution': 'cyan',
'gate_decision': 'yellow',
'prompt_delivery': 'magenta',
'tool_call_trace': 'green',
'rbac_decision': 'red',
'session_bind': 'gray',
};
export const EVENT_KIND_LABELS: Record<string, string> = {
'pipeline_execution': 'PIPELINE',
'stage_execution': 'STAGE',
'gate_decision': 'GATE',
'prompt_delivery': 'PROMPT',
'tool_call_trace': 'TOOL',
'rbac_decision': 'RBAC',
'session_bind': 'BIND',
};

View File

@@ -37,7 +37,7 @@ export function Timeline({ events, height, focusedIdx, showProject }: TimelinePr
return (
<Box flexDirection="column" flexGrow={1} paddingLeft={1}>
<Text bold>
Timeline <Text dimColor>({events.length} events{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ''})</Text>
Timeline <Text dimColor>({events.length} events{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ' \u00B7 following'})</Text>
</Text>
{visible.length === 0 && (
<Box marginTop={1}>

View File

@@ -11,7 +11,8 @@ export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
.description('Interactive MCP console — unified timeline with tools, provenance, and lab replay')
.argument('[project]', 'Project name to connect to')
.option('--stdin-mcp', 'Run inspector as MCP server over stdin/stdout (for Claude)')
.action(async (projectName: string | undefined, opts: { stdinMcp?: boolean }) => {
.option('--audit', 'Browse audit events from mcpd')
.action(async (projectName: string | undefined, opts: { stdinMcp?: boolean; audit?: boolean }) => {
let mcplocalUrl = 'http://localhost:3200';
if (deps.configLoader) {
mcplocalUrl = deps.configLoader().mcplocalUrl;
@@ -43,6 +44,20 @@ export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
}
}
// --audit: browse audit events from mcpd
if (opts.audit) {
let mcpdUrl = 'http://localhost:3100';
try {
const { loadConfig } = await import('../../config/index.js');
mcpdUrl = loadConfig().mcpdUrl;
} catch {
// Use default
}
const { renderAuditConsole } = await import('./audit-app.js');
await renderAuditConsole({ mcpdUrl, token, projectFilter: projectName });
return;
}
// Build endpoint URL only if project specified
let endpointUrl: string | undefined;
if (projectName) {

View File

@@ -728,13 +728,13 @@ function UnifiedApp({ projectName, endpointUrl, mcplocalUrl, token }: UnifiedApp
return;
}
if (key.pageDown) {
const nextIdx = Math.min(filteredEvents.length - 1, s.action.eventIdx + 1);
setState((prev) => ({ ...prev, focusedEventIdx: nextIdx, action: { type: 'detail', eventIdx: nextIdx, scrollOffset: 0, horizontalOffset: 0, searchMode: false, searchQuery: '', searchMatches: [], searchMatchIdx: -1 } }));
const pageSize = Math.max(1, Math.floor(stdout.rows * 0.35));
setState((prev) => ({ ...prev, action: { ...prev.action, scrollOffset: (prev.action as { scrollOffset: number }).scrollOffset + pageSize } as ActionState }));
return;
}
if (key.pageUp) {
const prevIdx = Math.max(0, s.action.eventIdx - 1);
setState((prev) => ({ ...prev, focusedEventIdx: prevIdx, action: { type: 'detail', eventIdx: prevIdx, scrollOffset: 0, horizontalOffset: 0, searchMode: false, searchQuery: '', searchMatches: [], searchMatchIdx: -1 } }));
const pageSize = Math.max(1, Math.floor(stdout.rows * 0.35));
setState((prev) => ({ ...prev, action: { ...prev.action, scrollOffset: Math.max(0, (prev.action as { scrollOffset: number }).scrollOffset - pageSize) } as ActionState }));
return;
}
if (input === 'p') {
@@ -1451,19 +1451,47 @@ function UnifiedApp({ projectName, endpointUrl, mcplocalUrl, token }: UnifiedApp
return;
}
// "a" resumes auto-scroll (follow newest events)
if (input === 'a') {
setState((prev) => ({ ...prev, focusedEventIdx: -1 }));
return;
}
// Arrows control event navigation when sidebar is hidden
if (key.downArrow) {
setState((prev) => ({
...prev,
focusedEventIdx: Math.min(filteredEvents.length - 1, prev.focusedEventIdx + 1),
}));
setState((prev) => {
if (prev.focusedEventIdx === -1) return prev; // already at bottom (auto-scroll)
return { ...prev, focusedEventIdx: Math.min(filteredEvents.length - 1, prev.focusedEventIdx + 1) };
});
return;
}
if (key.upArrow) {
setState((prev) => ({
...prev,
focusedEventIdx: prev.focusedEventIdx <= 0 ? -1 : prev.focusedEventIdx - 1,
}));
setState((prev) => {
if (prev.focusedEventIdx === -1) {
// Leave auto-scroll, focus last event
return filteredEvents.length > 0 ? { ...prev, focusedEventIdx: filteredEvents.length - 1 } : prev;
}
return { ...prev, focusedEventIdx: prev.focusedEventIdx <= 0 ? -1 : prev.focusedEventIdx - 1 };
});
return;
}
// PageDown/PageUp: jump by a page (vim-style)
if (key.pageDown) {
const pageSize = Math.max(1, stdout.rows - 8);
setState((prev) => {
if (prev.focusedEventIdx === -1) return prev; // already at bottom
const next = Math.min(filteredEvents.length - 1, prev.focusedEventIdx + pageSize);
return { ...prev, focusedEventIdx: next };
});
return;
}
if (key.pageUp) {
const pageSize = Math.max(1, stdout.rows - 8);
setState((prev) => {
const current = prev.focusedEventIdx === -1 ? filteredEvents.length - 1 : prev.focusedEventIdx;
const next = Math.max(0, current - pageSize);
return { ...prev, focusedEventIdx: next };
});
return;
}
if (input === 'G') {
@@ -1687,8 +1715,12 @@ function UnifiedApp({ projectName, endpointUrl, mcplocalUrl, token }: UnifiedApp
<Text dimColor>
{state.action.type === 'none'
? hasInteractiveSession
? `[\u2191\u2193] nav [Enter] detail [p] prov [Tab] toolbar [Esc] sidebar [q] quit`
: `[\u2191\u2193] nav [Enter] detail [p] prov [Esc] sidebar [q] quit`
? state.focusedEventIdx === -1
? `[\u2191] nav [PgUp] page [Enter] detail [p] prov [Tab] toolbar [Esc] sidebar [q] quit`
: `[\u2191\u2193] nav [PgUp/Dn] page [a] follow [Enter] detail [p] prov [Tab] toolbar [Esc] sidebar [q] quit`
: state.focusedEventIdx === -1
? `[\u2191] nav [PgUp] page [Enter] detail [p] prov [Esc] sidebar [q] quit`
: `[\u2191\u2193] nav [PgUp/Dn] page [a] follow [Enter] detail [p] prov [Esc] sidebar [q] quit`
: '[Esc] back'}
</Text>
</Box>

View File

@@ -384,7 +384,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
cmd.command('prompt')
.description('Create an approved prompt')
.argument('<name>', 'Prompt name (lowercase alphanumeric with hyphens)')
.option('--project <name>', 'Project name to scope the prompt to')
.option('-p, --project <name>', 'Project name to scope the prompt to')
.option('--content <text>', 'Prompt content text')
.option('--content-file <path>', 'Read prompt content from file')
.option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')
@@ -431,7 +431,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.alias('sa')
.description('Attach a server to a project')
.argument('<server>', 'Server name')
.option('--project <name>', 'Project name')
.option('-p, --project <name>', 'Project name')
.action(async (serverName: string, opts) => {
const projectName = opts.project as string | undefined;
if (!projectName) {
@@ -446,7 +446,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
cmd.command('promptrequest')
.description('Create a prompt request (pending proposal that needs approval)')
.argument('<name>', 'Prompt request name (lowercase alphanumeric with hyphens)')
.option('--project <name>', 'Project name to scope the prompt request to')
.option('-p, --project <name>', 'Project name to scope the prompt request to')
.option('--content <text>', 'Prompt content text')
.option('--content-file <path>', 'Read prompt content from file')
.option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')

View File

@@ -14,7 +14,7 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
.description('Delete a resource (server, instance, secret, project, user, group, rbac)')
.argument('<resource>', 'resource type')
.argument('<id>', 'resource ID or name')
.option('--project <name>', 'Project name (for serverattachment)')
.option('-p, --project <name>', 'Project name (for serverattachment)')
.action(async (resourceArg: string, idOrName: string, opts: { project?: string }) => {
const resource = resolveResource(resourceArg);

View File

@@ -276,7 +276,7 @@ export function createGetCommand(deps: GetCommandDeps): Command {
.argument('<resource>', 'resource type (servers, projects, instances, all)')
.argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.option('--project <name>', 'Filter by project')
.option('-p, --project <name>', 'Filter by project')
.option('-A, --all', 'Show all (including project-scoped) resources')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string; project?: string; all?: true }) => {
const resource = resolveResource(resourceArg);