feat(cli): restructure run folder and improve terminal UX (#384)

* feat: surface report at run root and nest run internals under .shannon

* feat: use plain-language wording in user-facing terminal messages

* feat(cli): guide users to watch scan progress and surface report path on start

* docs: sync run-folder layout and CLI wording across docs and comments

* feat(cli): add version command reporting package version or git SHA

* feat(cli): detect TTY for interactive prompts, color, and progress output

* docs: document --yes flag, version command, and tty module

* fix(cli): FORCE_COLOR precedence and plain uninstall --yes output

* fix(cli): respect empty NO_COLOR

* fix(cli): let NO_COLOR take precedence over FORCE_COLOR

* docs: mark claude-code-router integration as removed
This commit is contained in:
ezl-keygraph
2026-07-04 21:14:04 +05:30
committed by GitHub
parent 5a2f78c5d9
commit 00e56455df
27 changed files with 445 additions and 172 deletions
+11 -10
View File
@@ -1,5 +1,5 @@
/**
* `shannon logs` command — tail a workspace's workflow log.
* `shannon logs` command — tail a scan's live log.
*
* Uses chokidar for reliable cross-platform file watching and
* bounded synchronous reads to prevent duplicate output.
@@ -9,9 +9,10 @@ import fs from 'node:fs';
import path from 'node:path';
import { watch } from 'chokidar';
import { getWorkspacesDir } from '../home.js';
import { resolveRunFile } from '../paths.js';
// Match the exact line the worker writes — anchored to prevent false positives from agent output
const COMPLETION_PATTERN = /^Workflow (COMPLETED|FAILED)$/m;
const COMPLETION_PATTERN = /^Scan (COMPLETED|FAILED)$/m;
/** Read a byte range from a file and return it as a UTF-8 string. */
function readRange(filePath: string, start: number, end: number): string {
@@ -31,30 +32,30 @@ function resolveLogFile(workspaceId: string): string {
const workspacesDir = getWorkspacesDir();
// 1. Direct match
const directPath = path.join(workspacesDir, workspaceId, 'workflow.log');
const directPath = resolveRunFile(path.join(workspacesDir, workspaceId), 'workflow.log');
if (fs.existsSync(directPath)) return directPath;
// 2. Resume workflow ID (e.g. workspace_resume_123)
const resumeBase = workspaceId.replace(/_resume_\d+$/, '');
if (resumeBase !== workspaceId) {
const resumePath = path.join(workspacesDir, resumeBase, 'workflow.log');
const resumePath = resolveRunFile(path.join(workspacesDir, resumeBase), 'workflow.log');
if (fs.existsSync(resumePath)) return resumePath;
}
// 3. Named workspace ID (e.g. workspace_shannon-123)
const namedBase = workspaceId.replace(/_shannon-\d+$/, '');
if (namedBase !== workspaceId) {
const namedPath = path.join(workspacesDir, namedBase, 'workflow.log');
const namedPath = resolveRunFile(path.join(workspacesDir, namedBase), 'workflow.log');
if (fs.existsSync(namedPath)) return namedPath;
}
console.error(`ERROR: Workflow log not found for: ${workspaceId}`);
console.error(`ERROR: No scan found named: ${workspaceId}`);
console.error('');
console.error('Possible causes:');
console.error(" - Workflow hasn't started yet");
console.error(' - Workspace ID is incorrect');
console.error(" - The scan hasn't started yet");
console.error(' - The workspace name is incorrect');
console.error('');
console.error('Check the Temporal Web UI at http://localhost:8233 for workflow details');
console.error('Check the dashboard at http://localhost:8233 for scan details');
process.exit(1);
}
@@ -82,7 +83,7 @@ export function logs(workspaceId: string): void {
}
}
console.log(`Tailing workflow log: ${logFile}`);
console.log(`Tailing scan log: ${logFile}`);
// 1. Output existing content
if (flush()) {
+2
View File
@@ -10,12 +10,14 @@ import os from 'node:os';
import path from 'node:path';
import * as p from '@clack/prompts';
import { type ShannonConfig, saveConfig } from '../config/writer.js';
import { requireInteractive } from '../tty.js';
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
type Provider = 'anthropic' | 'custom_base_url' | 'bedrock' | 'vertex';
export async function setup(): Promise<void> {
requireInteractive('setup', 'For non-interactive use, export credentials as env vars (e.g. ANTHROPIC_API_KEY).');
p.intro('Shannon Setup');
// 1. Select provider
+52 -19
View File
@@ -12,8 +12,9 @@ import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.j
import { buildEnvFlags, loadEnv, validateCredentials } from '../env.js';
import { getCredentialsPath, getWorkspacesDir, initHome } from '../home.js';
import { isLocal } from '../mode.js';
import { resolveConfig, resolveRepo } from '../paths.js';
import { FINAL_REPORT_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js';
import { displaySplash } from '../splash.js';
import { stdoutIsTerminal } from '../tty.js';
export interface StartArgs {
url: string;
@@ -26,6 +27,29 @@ export interface StartArgs {
version: string;
}
/**
* Upgrade a pre-restructure workspace (flat layout, no INTERNAL_DIR) before it is mounted,
* so resume finds the old deliverables and their git checkpoints instead of re-running every
* agent. For a legacy run every top-level entry is internal, so move them all into INTERNAL_DIR
* (a same-filesystem rename carries the deliverables .git along).
*/
function migrateLegacyWorkspaceLayout(workspacePath: string): void {
const legacySessionJson = path.join(workspacePath, 'session.json');
const internalPath = path.join(workspacePath, INTERNAL_DIR);
if (!fs.existsSync(legacySessionJson) || fs.existsSync(internalPath)) {
return;
}
fs.mkdirSync(internalPath, { recursive: true });
for (const entry of fs.readdirSync(workspacePath)) {
if (entry === INTERNAL_DIR) {
continue;
}
fs.renameSync(path.join(workspacePath, entry), path.join(internalPath, entry));
}
console.log(`Migrated workspace to ${INTERNAL_DIR}/ layout: ${workspacePath}`);
}
export async function start(args: StartArgs): Promise<void> {
// 1. Initialize state directories and load env
initHome();
@@ -61,12 +85,17 @@ export async function start(args: StartArgs): Promise<void> {
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
// Workspace dir must be 0o777 so the container user (UID 1001) can create audit subdirs
// The run dir and its INTERNAL_DIR must be 0o777 so the container user can create audit
// subdirs and the overlay backing dirs.
const workspacePath = path.join(workspacesDir, workspace);
const internalPath = path.join(workspacePath, INTERNAL_DIR);
fs.mkdirSync(workspacePath, { recursive: true });
fs.chmodSync(workspacePath, 0o777);
migrateLegacyWorkspaceLayout(workspacePath);
fs.mkdirSync(internalPath, { recursive: true });
fs.chmodSync(internalPath, 0o777);
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
const dirPath = path.join(workspacePath, dir);
const dirPath = path.join(internalPath, dir);
fs.mkdirSync(dirPath, { recursive: true });
fs.chmodSync(dirPath, 0o777);
}
@@ -119,7 +148,7 @@ export async function start(args: StartArgs): Promise<void> {
const dockerExitCode = await new Promise<number>((resolve) => {
proc.once('exit', (code) => resolve(code ?? 1));
proc.once('error', (err) => {
console.error(`Failed to start worker: ${err.message}`);
console.error(`Failed to start the scan: ${err.message}`);
resolve(1);
});
});
@@ -129,7 +158,7 @@ export async function start(args: StartArgs): Promise<void> {
}
// Detect whether this is a fresh workspace or a resume by checking session.json existence
const sessionJson = path.join(workspacesDir, workspace, 'session.json');
const sessionJson = resolveRunFile(path.join(workspacesDir, workspace), 'session.json');
const isResume = fs.existsSync(sessionJson);
let initialResumeCount = 0;
if (isResume) {
@@ -141,8 +170,10 @@ export async function start(args: StartArgs): Promise<void> {
}
}
// Poll for workflow to register in session.json
process.stdout.write('Waiting for workflow to start...');
// Poll for workflow to register in session.json. Off-TTY, skip the dots and
// clear-line escape so redirected logs stay clean.
const animate = stdoutIsTerminal();
process.stdout.write('Waiting for the scan to start...');
let workflowId = '';
let started = false;
let attempts = 0;
@@ -151,7 +182,7 @@ export async function start(args: StartArgs): Promise<void> {
if (attempts > 60) {
clearInterval(pollInterval);
process.stdout.write('\n');
console.error('Timeout waiting for workflow to start');
console.error('Timed out waiting for the scan to start');
process.exit(1);
}
@@ -169,15 +200,15 @@ export async function start(args: StartArgs): Promise<void> {
// Latest workflow ID: last resume attempt, or originalWorkflowId for fresh scans
workflowId = resumeAttempts.at(-1)?.workflowId ?? session.session?.originalWorkflowId ?? '';
// Clear waiting line and show info
process.stdout.write('\r\x1b[K');
// Clear the waiting line, or just break it off-TTY
process.stdout.write(animate ? '\r\x1b[K' : '\n');
printInfo(args, workspace, workflowId, repo.hostPath, workspacesDir);
return;
}
} catch {
// File doesn't exist yet
}
process.stdout.write('.');
if (animate) process.stdout.write('.');
}, 2000);
// Stop the worker container only if it hasn't started yet
@@ -186,7 +217,7 @@ export async function start(args: StartArgs): Promise<void> {
if (cleaned || started) return;
cleaned = true;
clearInterval(pollInterval);
console.log(`\nStopping worker ${containerName}...`);
console.log('\nStopping scan...');
try {
execFileSync('docker', ['stop', containerName], { stdio: 'pipe' });
} catch {
@@ -224,8 +255,10 @@ function printInfo(
workspacesDir: string,
): void {
const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`;
const reportsPath = path.join(workspacesDir, workspace);
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_FILENAME);
console.log(' Scan started — it runs in the background, so you can close this terminal.');
console.log('');
console.log(` Target: ${args.url}`);
console.log(` Repository: ${repoPath}`);
console.log(` Workspace: ${workspace}`);
@@ -252,15 +285,15 @@ function printInfo(
}
console.log('');
console.log(' Monitor:');
console.log(' Watch scan progress:');
console.log(` Live logs: ${logsCmd}`);
if (workflowId) {
console.log(` Web UI: http://localhost:8233/namespaces/default/workflows/${workflowId}`);
console.log(` Dashboard: http://localhost:8233/namespaces/default/workflows/${workflowId}`);
} else {
console.log(' Web UI: http://localhost:8233');
console.log(' Dashboard: http://localhost:8233');
}
console.log(` Logs: ${logsCmd}`);
console.log('');
console.log(' Output:');
console.log(` Reports: ${reportsPath}/`);
console.log(' Report (when the scan finishes):');
console.log(` ${reportPath}`);
console.log('');
}
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* `shannon status` command — show running workers and Temporal health.
* `shannon status` command — show running scans and Temporal health.
*/
import { isTemporalReady, listRunningWorkers } from '../docker.js';
@@ -9,16 +9,16 @@ export function status(): void {
const temporalUp = isTemporalReady();
console.log(`Temporal: ${temporalUp ? 'running' : 'not running'}`);
if (temporalUp) {
console.log(' Web UI: http://localhost:8233');
console.log(' Dashboard: http://localhost:8233');
}
console.log('');
// 2. Running workers
// 2. Running scans
const workers = listRunningWorkers();
if (workers) {
console.log('Workers:');
console.log('Running scans:');
console.log(workers);
} else {
console.log('Workers: none running');
console.log('No scans running');
}
}
+4 -2
View File
@@ -4,9 +4,11 @@
import * as p from '@clack/prompts';
import { stopInfra, stopWorkers } from '../docker.js';
import { requireInteractive } from '../tty.js';
export async function stop(clean: boolean): Promise<void> {
if (clean) {
export async function stop(clean: boolean, yes: boolean): Promise<void> {
if (clean && !yes) {
requireInteractive('stop --clean', 'Re-run with --yes to skip this confirmation.');
const confirmed = await p.confirm({
message: 'This will stop all running scans and remove the Temporal data. Continue?',
});
+30 -12
View File
@@ -7,24 +7,34 @@ import os from 'node:os';
import path from 'node:path';
import * as p from '@clack/prompts';
import { stopInfra, stopWorkers } from '../docker.js';
import { requireInteractive } from '../tty.js';
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
export async function uninstall(): Promise<void> {
p.intro('Shannon Uninstall');
export async function uninstall(yes: boolean): Promise<void> {
const interactive = !yes;
if (interactive) p.intro('Shannon Uninstall');
if (!fs.existsSync(SHANNON_HOME)) {
p.log.info('Nothing to remove. Shannon is not configured on this machine.');
p.outro('Done.');
const message = 'Nothing to remove. Shannon is not configured on this machine.';
if (interactive) {
p.log.info(message);
p.outro('Done.');
} else {
console.log(message);
}
return;
}
const confirmed = await p.confirm({
message: 'This will permanently remove all past scan data, saved configurations, and API keys. Continue?',
});
if (p.isCancel(confirmed) || !confirmed) {
p.cancel('Aborted.');
process.exit(0);
if (interactive) {
requireInteractive('uninstall', 'Re-run with --yes to skip this confirmation.');
const confirmed = await p.confirm({
message: 'This will permanently remove all past scan data, saved configurations, and API keys. Continue?',
});
if (p.isCancel(confirmed) || !confirmed) {
p.cancel('Aborted.');
process.exit(0);
}
}
// Stop any running containers first
@@ -32,6 +42,14 @@ export async function uninstall(): Promise<void> {
stopInfra(false);
fs.rmSync(SHANNON_HOME, { recursive: true, force: true });
p.log.success('All Shannon data has been removed.');
p.outro('Shannon has been uninstalled. Run `npx @keygraph/shannon setup` to start fresh.');
const done = 'All Shannon data has been removed.';
const hint = 'Shannon has been uninstalled. Run `npx @keygraph/shannon setup` to start fresh.';
if (interactive) {
p.log.success(done);
p.outro(hint);
} else {
console.log(done);
console.log(hint);
}
}