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
+28 -20
View File
@@ -12,7 +12,7 @@
*/
import path from 'node:path';
import { WORKSPACES_DIR } from '../paths.js';
import { INTERNAL_DIR, WORKSPACES_DIR } from '../paths.js';
import { ensureDirectory } from '../utils/file-io.js';
export type { SessionMetadata } from '../types/audit.js';
@@ -35,8 +35,9 @@ export function generateSessionIdentifier(sessionMetadata: SessionMetadata): str
}
/**
* Generate path to audit log directory for a session
* Uses custom outputPath if provided, otherwise defaults to WORKSPACES_DIR
* Generate path to a run directory for a session (its top level).
* Uses custom outputPath if provided, otherwise defaults to WORKSPACES_DIR.
* Only the final report lives here; all internals live under INTERNAL_DIR.
*/
export function generateAuditPath(sessionMetadata: SessionMetadata): string {
const sessionIdentifier = generateSessionIdentifier(sessionMetadata);
@@ -44,6 +45,14 @@ export function generateAuditPath(sessionMetadata: SessionMetadata): string {
return path.join(baseDir, sessionIdentifier);
}
/**
* Generate path to the hidden internals directory inside a run directory.
* Holds logs, prompts, session state, deliverables, and browser artifacts.
*/
export function generateInternalPath(sessionMetadata: SessionMetadata): string {
return path.join(generateAuditPath(sessionMetadata), INTERNAL_DIR);
}
/**
* Generate path to agent log file
*/
@@ -53,25 +62,25 @@ export function generateLogPath(
timestamp: number,
attemptNumber: number,
): string {
const auditPath = generateAuditPath(sessionMetadata);
const internalPath = generateInternalPath(sessionMetadata);
const filename = `${timestamp}_${agentName}_attempt-${attemptNumber}.log`;
return path.join(auditPath, 'agents', filename);
return path.join(internalPath, 'agents', filename);
}
/**
* Generate path to prompt snapshot file
*/
export function generatePromptPath(sessionMetadata: SessionMetadata, agentName: string): string {
const auditPath = generateAuditPath(sessionMetadata);
return path.join(auditPath, 'prompts', `${agentName}.md`);
const internalPath = generateInternalPath(sessionMetadata);
return path.join(internalPath, 'prompts', `${agentName}.md`);
}
/**
* Generate path to session.json file
*/
export function generateSessionJsonPath(sessionMetadata: SessionMetadata): string {
const auditPath = generateAuditPath(sessionMetadata);
return path.join(auditPath, 'session.json');
const internalPath = generateInternalPath(sessionMetadata);
return path.join(internalPath, 'session.json');
}
/**
@@ -79,29 +88,28 @@ export function generateSessionJsonPath(sessionMetadata: SessionMetadata): strin
* validator and consumed by downstream agents via `_shared-session.txt`.
*/
export function authStateFile(sessionMetadata: SessionMetadata): string {
return path.join(generateAuditPath(sessionMetadata), 'auth-state.json');
return path.join(generateInternalPath(sessionMetadata), 'auth-state.json');
}
/**
* Generate path to workflow.log file
*/
export function generateWorkflowLogPath(sessionMetadata: SessionMetadata): string {
const auditPath = generateAuditPath(sessionMetadata);
return path.join(auditPath, 'workflow.log');
const internalPath = generateInternalPath(sessionMetadata);
return path.join(internalPath, 'workflow.log');
}
/**
* Initialize audit directory structure for a session
* Creates: workspaces/{sessionId}/, agents/, prompts/, deliverables/
* Initialize audit directory structure for a session.
* Creates: workspaces/{sessionId}/.shannon/{agents,prompts}. The deliverables,
* scratchpad, and browser dirs are created host-side and bind-mounted in.
*/
export async function initializeAuditStructure(sessionMetadata: SessionMetadata): Promise<void> {
const auditPath = generateAuditPath(sessionMetadata);
const agentsPath = path.join(auditPath, 'agents');
const promptsPath = path.join(auditPath, 'prompts');
const deliverablesPath = path.join(auditPath, 'deliverables');
const internalPath = generateInternalPath(sessionMetadata);
const agentsPath = path.join(internalPath, 'agents');
const promptsPath = path.join(internalPath, 'prompts');
await ensureDirectory(auditPath);
await ensureDirectory(internalPath);
await ensureDirectory(agentsPath);
await ensureDirectory(promptsPath);
await ensureDirectory(deliverablesPath);
}
+2 -2
View File
@@ -80,7 +80,7 @@ export class WorkflowLogger {
private async writeHeader(): Promise<void> {
const lines = [
`================================================================================`,
`Shannon Pentest - Workflow Log`,
`Shannon Pentest - Scan Log`,
`================================================================================`,
`Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}`,
`Target URL: ${this.sessionMetadata.webUrl}`,
@@ -337,7 +337,7 @@ export class WorkflowLogger {
const lines: string[] = [
'',
'================================================================================',
`Workflow ${status}`,
`Scan ${status}`,
'────────────────────────────────────────',
`Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}`,
`Status: ${summary.status}`,
+30
View File
@@ -15,6 +15,36 @@ export const DEFAULT_DELIVERABLES_SUBDIR = '.shannon/deliverables';
/** Default audit log directory */
export const DEFAULT_AUDIT_DIR = './workspaces';
/**
* Hidden subdirectory inside each run directory that holds all internals
* (logs, prompts, session state, deliverables, browser artifacts). Keeps the
* run folder's top level clean so only the final report is visible.
*/
export const INTERNAL_DIR = '.shannon';
/** Filename of the assembled report inside the deliverables dir (internal, source of the surfaced copy) */
export const ASSEMBLED_REPORT_FILENAME = 'comprehensive_security_assessment_report.md';
/** Filename of the human-facing final report surfaced at the run directory root */
export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md';
/**
* Resolve the session.json path for a run directory, preferring the current
* `.shannon/` location and falling back to the legacy run-root location so
* pre-restructure workspaces remain listable and resumable.
*/
export function resolveSessionJsonPath(runDir: string): string {
const current = path.join(runDir, INTERNAL_DIR, 'session.json');
if (fs.existsSync(current)) {
return current;
}
const legacy = path.join(runDir, 'session.json');
if (fs.existsSync(legacy)) {
return legacy;
}
return current;
}
/**
* Resolve the deliverables directory for a given repoPath and optional subdir override.
* @param repoPath - Absolute path to the target repository
+1 -1
View File
@@ -20,4 +20,4 @@ export type { ContainerDependencies } from './container.js';
export { Container, getContainer, getOrCreateContainer, removeContainer, setContainerFactory } from './container.js';
export { ExploitationCheckerService } from './exploitation-checker.js';
export { loadPrompt } from './prompt-manager.js';
export { assembleFinalReport, injectModelIntoReport } from './reporting.js';
export { assembleFinalReport, copyReportToRunRoot, injectModelIntoReport } from './reporting.js';
+27 -7
View File
@@ -5,7 +5,7 @@
// as published by the Free Software Foundation.
import { fs, path } from 'zx';
import { deliverablesDir } from '../paths.js';
import { ASSEMBLED_REPORT_FILENAME, deliverablesDir, FINAL_REPORT_FILENAME, resolveSessionJsonPath } from '../paths.js';
import type { ActivityLogger } from '../types/activity-logger.js';
import { ErrorCode } from '../types/errors.js';
import { PentestError } from './error-handling.js';
@@ -68,7 +68,7 @@ export async function assembleFinalReport(
}
const finalContent = sections.join('\n\n');
const finalReportPath = path.join(dir, 'comprehensive_security_assessment_report.md');
const finalReportPath = path.join(dir, ASSEMBLED_REPORT_FILENAME);
try {
await fs.ensureDir(dir);
@@ -97,7 +97,7 @@ export async function injectModelIntoReport(
logger: ActivityLogger,
): Promise<void> {
// 1. Read session.json to get model information
const sessionJsonPath = path.join(outputPath, 'session.json');
const sessionJsonPath = resolveSessionJsonPath(outputPath);
if (!(await fs.pathExists(sessionJsonPath))) {
logger.warn('session.json not found, skipping model injection');
@@ -129,10 +129,7 @@ export async function injectModelIntoReport(
logger.info(`Injecting model info into report: ${modelStr}`);
// 3. Read the final report
const reportPath = path.join(
deliverablesDir(repoPath, deliverablesSubdir),
'comprehensive_security_assessment_report.md',
);
const reportPath = path.join(deliverablesDir(repoPath, deliverablesSubdir), ASSEMBLED_REPORT_FILENAME);
if (!(await fs.pathExists(reportPath))) {
logger.warn('Final report not found, skipping model injection');
@@ -167,3 +164,26 @@ export async function injectModelIntoReport(
// 5. Write modified report back
await fs.writeFile(reportPath, reportContent);
}
/**
* Surface the assembled report at the run directory's top level as the single
* human-facing deliverable, so a customer opening the run folder sees only the
* report. The source stays in the deliverables dir (git-checkpointed, used by resume).
*/
export async function copyReportToRunRoot(
repoPath: string,
deliverablesSubdir: string | undefined,
runDir: string,
logger: ActivityLogger,
): Promise<void> {
const source = path.join(deliverablesDir(repoPath, deliverablesSubdir), ASSEMBLED_REPORT_FILENAME);
if (!(await fs.pathExists(source))) {
logger.warn(`Final report not found, skipping ${FINAL_REPORT_FILENAME}`);
return;
}
const destination = path.join(runDir, FINAL_REPORT_FILENAME);
await fs.copy(source, destination, { overwrite: true });
logger.info(`Surfaced report at ${destination}`);
}
+23 -7
View File
@@ -22,10 +22,10 @@ import { writePlaywrightStealthConfig } from '../ai/playwright-config-writer.js'
import { writeUserSettingsForCodePathAvoids } from '../ai/settings-writer.js';
import { AuditSession } from '../audit/index.js';
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
import { authStateFile, generateSessionJsonPath, type SessionMetadata } from '../audit/utils.js';
import { authStateFile, generateAuditPath, generateSessionJsonPath, type SessionMetadata } from '../audit/utils.js';
import type { WorkflowSummary } from '../audit/workflow-logger.js';
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
import { DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir } from '../paths.js';
import { DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir, resolveSessionJsonPath } from '../paths.js';
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
import { classifyErrorForTemporal, PentestError } from '../services/error-handling.js';
import { ExploitationCheckerService } from '../services/exploitation-checker.js';
@@ -33,7 +33,7 @@ import { renderFindingsFromQueues } from '../services/findings-renderer.js';
import { executeGitCommandWithRetry } from '../services/git-manager.js';
import { runPreflightChecks } from '../services/preflight.js';
import type { ExploitationDecision, VulnType } from '../services/queue-validation.js';
import { assembleFinalReport, injectModelIntoReport } from '../services/reporting.js';
import { assembleFinalReport, copyReportToRunRoot, injectModelIntoReport } from '../services/reporting.js';
import { validateAuthentication } from '../services/validate-authentication.js';
import { AGENTS } from '../session-manager.js';
import type { AgentName } from '../types/agents.js';
@@ -756,8 +756,8 @@ export async function loadResumeState(
expectedRepoPath: string,
deliverablesSubdir?: string,
): Promise<ResumeState> {
// 1. Validate workspace exists
const sessionPath = path.join('./workspaces', workspaceName, 'session.json');
// 1. Validate workspace exists (prefers .shannon/, falls back to legacy run-root layout)
const sessionPath = resolveSessionJsonPath(path.join('./workspaces', workspaceName));
const exists = await fileExists(sessionPath);
if (!exists) {
@@ -1055,7 +1055,23 @@ export async function logWorkflowComplete(input: ActivityInput, summary: Workflo
// 5. Write completion entry to workflow.log
await auditSession.logWorkflowComplete(cumulativeSummary);
// 6. Drop the authenticated browser session
// 6. Surface the final report at the run root. Done here (not in the report phase)
// so it also runs when a resume skips an already-complete report phase.
if (summary.status === 'completed') {
try {
await copyReportToRunRoot(
input.repoPath,
input.deliverablesSubdir,
generateAuditPath(sessionMetadata),
createActivityLogger(),
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
console.warn(`Failed to surface report at run root: ${detail}`);
}
}
// 7. Drop the authenticated browser session
try {
await fs.rm(authStateFile(sessionMetadata), { force: true });
} catch (error) {
@@ -1063,7 +1079,7 @@ export async function logWorkflowComplete(input: ActivityInput, summary: Workflo
console.warn(`Failed to clean up auth-state.json: ${detail}`);
}
// 7. Clean up container
// 8. Clean up container
removeContainer(workflowId);
}
+17 -9
View File
@@ -35,7 +35,7 @@ import { bundleWorkflowCode, NativeConnection, Worker } from '@temporalio/worker
import dotenv from 'dotenv';
import { sanitizeHostname } from '../audit/utils.js';
import { parseConfig } from '../config-parser.js';
import { deliverablesDir } from '../paths.js';
import { ASSEMBLED_REPORT_FILENAME, deliverablesDir, FINAL_REPORT_FILENAME, resolveSessionJsonPath } from '../paths.js';
import type { PipelineConfig, VulnClass } from '../types/config.js';
import { fileExists, readJson } from '../utils/file-io.js';
import * as activities from './activities.js';
@@ -171,7 +171,7 @@ interface WorkspaceResolution {
}
async function terminateExistingWorkflows(client: Client, workspaceName: string): Promise<string[]> {
const sessionPath = path.join('./workspaces', workspaceName, 'session.json');
const sessionPath = resolveSessionJsonPath(path.join('./workspaces', workspaceName));
if (!(await fileExists(sessionPath))) {
throw new Error(`Workspace not found: ${workspaceName}\n` + `Expected path: ${sessionPath}`);
@@ -192,16 +192,16 @@ async function terminateExistingWorkflows(client: Client, workspaceName: string)
const description = await handle.describe();
if (description.status.name === 'RUNNING') {
console.log(`Terminating running workflow: ${wfId}`);
console.log(`Terminating running scan: ${wfId}`);
await handle.terminate('Superseded by resume workflow');
terminated.push(wfId);
console.log(`Terminated: ${wfId}`);
} else {
console.log(`Workflow already ${description.status.name}: ${wfId}`);
console.log(`Scan already ${description.status.name}: ${wfId}`);
}
} catch (error) {
if (error instanceof WorkflowNotFoundError) {
console.log(`Workflow not found (already cleaned up): ${wfId}`);
console.log(`Scan not found (already cleaned up): ${wfId}`);
} else {
console.log(`Failed to terminate ${wfId}: ${error}`);
}
@@ -224,7 +224,7 @@ async function resolveWorkspace(client: Client, args: CliArgs): Promise<Workspac
}
const workspace = args.resumeFromWorkspace;
const sessionPath = path.join('./workspaces', workspace, 'session.json');
const sessionPath = resolveSessionJsonPath(path.join('./workspaces', workspace));
const workspaceExists = await fileExists(sessionPath);
if (workspaceExists) {
@@ -233,7 +233,7 @@ async function resolveWorkspace(client: Client, args: CliArgs): Promise<Workspac
const terminatedWorkflows = await terminateExistingWorkflows(client, workspace);
if (terminatedWorkflows.length > 0) {
console.log(`Terminated ${terminatedWorkflows.length} previous workflow(s)\n`);
console.log(`Terminated ${terminatedWorkflows.length} previous scan(s)\n`);
}
const session = await readJson<SessionJson>(sessionPath);
@@ -355,7 +355,9 @@ async function waitForWorkflowResult(
if (workspace.isResume) {
try {
const session = await readJson<SessionJson>(path.join('./workspaces', workspace.sessionId, 'session.json'));
const session = await readJson<SessionJson>(
resolveSessionJsonPath(path.join('./workspaces', workspace.sessionId)),
);
console.log(`Cumulative cost: $${session.metrics.total_cost_usd.toFixed(4)}`);
} catch {
// Non-fatal
@@ -393,6 +395,12 @@ function copyDeliverables(repoPath: string, outputPath: string): void {
fs.cpSync(src, dest, { recursive: true });
}
// Surface the report under its human-facing name alongside the raw deliverables
const assembledReport = path.join(outputDir, ASSEMBLED_REPORT_FILENAME);
if (fs.existsSync(assembledReport)) {
fs.copyFileSync(assembledReport, path.join(outputPath, FINAL_REPORT_FILENAME));
}
console.log(`Copied ${files.length} deliverable(s) to ${outputPath}`);
}
@@ -412,7 +420,7 @@ async function run(): Promise<void> {
try {
// 3. Bundle workflows and create worker on per-invocation task queue
console.log('Bundling workflows...');
console.log('Preparing scan...');
const workflowBundle = await bundleWorkflowCode({
workflowsPath: path.join(__dirname, 'workflows.js'),
});
+2 -2
View File
@@ -20,7 +20,7 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { WORKSPACES_DIR as DEFAULT_WORKSPACES_DIR } from '../paths.js';
import { WORKSPACES_DIR as DEFAULT_WORKSPACES_DIR, resolveSessionJsonPath } from '../paths.js';
interface SessionJson {
session: {
@@ -82,7 +82,7 @@ async function listWorkspaces(): Promise<void> {
const workspaces: WorkspaceInfo[] = [];
for (const entry of entries) {
const sessionPath = path.join(workspacesDir, entry, 'session.json');
const sessionPath = resolveSessionJsonPath(path.join(workspacesDir, entry));
try {
const content = await fs.readFile(sessionPath, 'utf8');
const data = JSON.parse(content) as SessionJson;