solidity-argus 0.1.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/AGENTS.md +3 -3
  2. package/README.md +229 -13
  3. package/package.json +37 -8
  4. package/skills/INVENTORY.md +88 -57
  5. package/skills/README.md +72 -6
  6. package/skills/case-studies/beanstalk-governance/SKILL.md +52 -0
  7. package/skills/case-studies/bzx-flash-loan/SKILL.md +53 -0
  8. package/skills/case-studies/cream-finance/SKILL.md +52 -0
  9. package/skills/case-studies/curve-reentrancy/SKILL.md +52 -0
  10. package/skills/case-studies/dao-hack/SKILL.md +51 -0
  11. package/skills/case-studies/euler-finance/SKILL.md +52 -0
  12. package/skills/case-studies/harvest-finance/SKILL.md +52 -0
  13. package/skills/case-studies/level-finance/SKILL.md +51 -0
  14. package/skills/case-studies/mango-markets/SKILL.md +53 -0
  15. package/skills/case-studies/nomad-bridge/SKILL.md +51 -0
  16. package/skills/case-studies/parity-multisig/SKILL.md +55 -0
  17. package/skills/case-studies/poly-network/SKILL.md +51 -0
  18. package/skills/case-studies/rari-fuse/SKILL.md +51 -0
  19. package/skills/case-studies/ronin-bridge/SKILL.md +52 -0
  20. package/skills/case-studies/wormhole-bridge/SKILL.md +51 -0
  21. package/skills/checklists/cyfrin-defi-core/SKILL.md +3 -0
  22. package/skills/manifests/cyfrin.json +16 -0
  23. package/skills/manifests/defifofum.json +25 -0
  24. package/skills/manifests/kadenzipfel.json +48 -0
  25. package/skills/manifests/scvd.json +9 -0
  26. package/skills/manifests/smartbugs.json +9 -0
  27. package/skills/manifests/solodit.json +9 -0
  28. package/skills/manifests/sunweb3sec.json +9 -0
  29. package/skills/manifests/trailofbits.json +9 -0
  30. package/skills/methodology/audit-workflow/SKILL.md +3 -0
  31. package/skills/protocol-patterns/amm-dex/SKILL.md +3 -0
  32. package/skills/references/exploit-reference/SKILL.md +3 -0
  33. package/skills/vulnerability-patterns/access-control/SKILL.md +27 -0
  34. package/skills/vulnerability-patterns/arbitrary-storage-location/SKILL.md +13 -1
  35. package/skills/vulnerability-patterns/assert-violation/SKILL.md +8 -1
  36. package/skills/vulnerability-patterns/asserting-contract-from-code-size/SKILL.md +12 -1
  37. package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +8 -1
  38. package/skills/vulnerability-patterns/cross-chain-bridge-vulnerabilities/SKILL.md +217 -0
  39. package/skills/vulnerability-patterns/default-visibility/SKILL.md +13 -1
  40. package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +8 -1
  41. package/skills/vulnerability-patterns/dos-gas-limit/SKILL.md +8 -1
  42. package/skills/vulnerability-patterns/dos-revert/SKILL.md +14 -1
  43. package/skills/vulnerability-patterns/erc4626-exchange-rate-manipulation/SKILL.md +64 -0
  44. package/skills/vulnerability-patterns/fee-on-transfer-tokens/SKILL.md +93 -0
  45. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +13 -0
  46. package/skills/vulnerability-patterns/floating-pragma/SKILL.md +8 -1
  47. package/skills/vulnerability-patterns/front-running-attacks/SKILL.md +209 -0
  48. package/skills/vulnerability-patterns/gas-optimization-patterns/SKILL.md +203 -0
  49. package/skills/vulnerability-patterns/governance-attacks/SKILL.md +208 -0
  50. package/skills/vulnerability-patterns/hash-collision/SKILL.md +8 -1
  51. package/skills/vulnerability-patterns/inadherence-to-standards/SKILL.md +12 -1
  52. package/skills/vulnerability-patterns/incorrect-constructor/SKILL.md +8 -1
  53. package/skills/vulnerability-patterns/incorrect-inheritance-order/SKILL.md +8 -1
  54. package/skills/vulnerability-patterns/insufficient-gas-griefing/SKILL.md +12 -1
  55. package/skills/vulnerability-patterns/lack-of-precision/SKILL.md +7 -1
  56. package/skills/vulnerability-patterns/logic-errors/SKILL.md +10 -0
  57. package/skills/vulnerability-patterns/missing-parameter-bounds/SKILL.md +44 -0
  58. package/skills/vulnerability-patterns/missing-protection-signature-replay/SKILL.md +17 -1
  59. package/skills/vulnerability-patterns/msgvalue-loop/SKILL.md +12 -1
  60. package/skills/vulnerability-patterns/off-by-one/SKILL.md +7 -1
  61. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +22 -0
  62. package/skills/vulnerability-patterns/outdated-compiler-version/SKILL.md +8 -1
  63. package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +11 -1
  64. package/skills/vulnerability-patterns/proxy-vulnerabilities/SKILL.md +209 -0
  65. package/skills/vulnerability-patterns/reentrancy/SKILL.md +22 -0
  66. package/skills/vulnerability-patterns/shadowing-state-variables/SKILL.md +8 -1
  67. package/skills/vulnerability-patterns/share-accounting-desynchronization/SKILL.md +44 -0
  68. package/skills/vulnerability-patterns/signature-malleability/SKILL.md +11 -1
  69. package/skills/vulnerability-patterns/stateful-parameter-update-drift/SKILL.md +44 -0
  70. package/skills/vulnerability-patterns/unbounded-return-data/SKILL.md +12 -1
  71. package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +13 -1
  72. package/skills/vulnerability-patterns/unencrypted-private-data-on-chain/SKILL.md +8 -1
  73. package/skills/vulnerability-patterns/unexpected-ecrecover-null-address/SKILL.md +8 -1
  74. package/skills/vulnerability-patterns/uninitialized-storage-pointer/SKILL.md +8 -1
  75. package/skills/vulnerability-patterns/unsafe-erc20-transfers/SKILL.md +132 -0
  76. package/skills/vulnerability-patterns/unsafe-low-level-call/SKILL.md +12 -1
  77. package/skills/vulnerability-patterns/unsecure-signatures/SKILL.md +12 -1
  78. package/skills/vulnerability-patterns/unsupported-opcodes/SKILL.md +11 -1
  79. package/skills/vulnerability-patterns/unused-variables/SKILL.md +8 -1
  80. package/skills/vulnerability-patterns/use-of-deprecated-functions/SKILL.md +8 -1
  81. package/skills/vulnerability-patterns/weak-sources-randomness/SKILL.md +8 -1
  82. package/skills/vulnerability-patterns/weird-tokens/SKILL.md +10 -0
  83. package/skills/vulnerability-patterns/zero-address-misconfiguration/SKILL.md +48 -0
  84. package/src/agents/argus-prompt.ts +27 -10
  85. package/src/agents/pythia-prompt.ts +7 -8
  86. package/src/agents/scribe-prompt.ts +10 -5
  87. package/src/agents/sentinel-prompt.ts +36 -7
  88. package/src/cli/cli-output.ts +16 -0
  89. package/src/cli/cli-program.ts +29 -22
  90. package/src/cli/commands/check-skills.ts +135 -0
  91. package/src/cli/commands/doctor.ts +303 -23
  92. package/src/cli/commands/init.ts +8 -6
  93. package/src/cli/commands/install.ts +10 -8
  94. package/src/cli/commands/lint-skills.ts +118 -0
  95. package/src/cli/index.ts +5 -5
  96. package/src/cli/tui-prompts.ts +4 -2
  97. package/src/cli/types.ts +3 -3
  98. package/src/config/index.ts +1 -1
  99. package/src/config/loader.ts +4 -6
  100. package/src/config/schema.ts +6 -5
  101. package/src/config/types.ts +2 -2
  102. package/src/constants/defaults.ts +2 -0
  103. package/src/create-hooks.ts +225 -29
  104. package/src/create-managers.ts +10 -8
  105. package/src/create-tools.ts +14 -8
  106. package/src/features/background-agent/background-manager.ts +93 -87
  107. package/src/features/background-agent/index.ts +1 -1
  108. package/src/features/context-monitor/context-monitor.ts +3 -3
  109. package/src/features/context-monitor/index.ts +2 -2
  110. package/src/features/error-recovery/session-recovery.ts +2 -4
  111. package/src/features/error-recovery/tool-error-recovery.ts +79 -19
  112. package/src/features/index.ts +5 -5
  113. package/src/features/persistent-state/audit-state-manager.ts +158 -52
  114. package/src/features/persistent-state/global-run-index.ts +38 -0
  115. package/src/features/persistent-state/index.ts +1 -1
  116. package/src/features/persistent-state/run-journal.ts +86 -0
  117. package/src/hooks/agent-tracker.ts +53 -0
  118. package/src/hooks/compaction-hook.ts +46 -37
  119. package/src/hooks/config-handler.ts +31 -11
  120. package/src/hooks/context-budget.ts +42 -0
  121. package/src/hooks/event-hook.ts +48 -23
  122. package/src/hooks/hook-system.ts +4 -4
  123. package/src/hooks/index.ts +5 -5
  124. package/src/hooks/knowledge-sync-hook.ts +19 -21
  125. package/src/hooks/recon-context-builder.ts +66 -0
  126. package/src/hooks/safe-create-hook.ts +9 -11
  127. package/src/hooks/system-prompt-hook.ts +128 -0
  128. package/src/hooks/tool-tracking-hook.ts +162 -29
  129. package/src/hooks/types.ts +2 -1
  130. package/src/index.ts +23 -13
  131. package/src/knowledge/retry.ts +53 -0
  132. package/src/knowledge/scvd-client.ts +103 -83
  133. package/src/knowledge/scvd-errors.ts +89 -0
  134. package/src/knowledge/scvd-index.ts +110 -62
  135. package/src/knowledge/scvd-sync.ts +223 -47
  136. package/src/knowledge/source-manifest.ts +102 -0
  137. package/src/managers/index.ts +1 -1
  138. package/src/managers/types.ts +19 -14
  139. package/src/plugin-interface.ts +19 -8
  140. package/src/shared/binary-utils.ts +44 -34
  141. package/src/shared/deep-merge.ts +55 -36
  142. package/src/shared/file-utils.ts +21 -19
  143. package/src/shared/index.ts +11 -5
  144. package/src/shared/jsonc-parser.ts +123 -28
  145. package/src/shared/logger.ts +91 -17
  146. package/src/shared/project-utils.ts +30 -0
  147. package/src/skills/analysis/cluster.ts +414 -0
  148. package/src/skills/analysis/gates.ts +227 -0
  149. package/src/skills/analysis/index.ts +33 -0
  150. package/src/skills/analysis/normalize.ts +217 -0
  151. package/src/skills/analysis/similarity.ts +224 -0
  152. package/src/skills/argus-skill-resolver.ts +237 -0
  153. package/src/skills/skill-schema.ts +99 -0
  154. package/src/solodit-lifecycle.ts +202 -0
  155. package/src/state/audit-state.ts +10 -8
  156. package/src/state/finding-store.ts +68 -55
  157. package/src/state/types.ts +96 -44
  158. package/src/tools/argus-skill-load-tool.ts +78 -0
  159. package/src/tools/contract-analyzer-tool.ts +60 -77
  160. package/src/tools/forge-coverage-tool.ts +226 -0
  161. package/src/tools/forge-fuzz-tool.ts +127 -127
  162. package/src/tools/forge-test-tool.ts +153 -157
  163. package/src/tools/gas-analysis-tool.ts +264 -0
  164. package/src/tools/pattern-checker-tool.ts +206 -167
  165. package/src/tools/pattern-loader.ts +77 -0
  166. package/src/tools/pattern-schema.ts +51 -0
  167. package/src/tools/proxy-detection-tool.ts +224 -0
  168. package/src/tools/report-generator-tool.ts +333 -142
  169. package/src/tools/slither-tool.ts +300 -210
  170. package/src/tools/solodit-search-tool.ts +255 -80
  171. package/src/tools/sync-knowledge-tool.ts +7 -11
  172. package/src/utils/audit-artifact-detector.ts +118 -0
  173. package/src/utils/dependency-scanner.ts +93 -0
  174. package/src/utils/project-detector.ts +175 -86
  175. package/src/utils/solidity-parser.ts +112 -67
  176. package/src/utils/solodit-health.ts +29 -0
  177. package/src/hooks/event-hook-v2.ts +0 -99
  178. package/src/state/plugin-state.ts +0 -14
@@ -1,45 +1,52 @@
1
- import type { CliCommand } from "./types";
2
- import { doctorCommand } from "./commands/doctor";
3
- import { initCommand } from "./commands/init";
4
- import { installCommand } from "./commands/install";
1
+ import { cliOutput } from "./cli-output"
2
+ import { checkSkillsCommand } from "./commands/check-skills"
3
+ import { doctorCommand } from "./commands/doctor"
4
+ import { initCommand } from "./commands/init"
5
+ import { installCommand } from "./commands/install"
6
+ import { lintSkillsCommand } from "./commands/lint-skills"
7
+ import type { CliCommand } from "./types"
5
8
 
6
9
  const HELP_TEXT = `argus — Solidity Security Auditor for OpenCode
7
10
 
8
11
  Commands:
9
- doctor Check Slither/Foundry installation and config health
10
- init Create solidity-argus config file
11
- install Configure argus plugin in opencode config
12
- `;
12
+ doctor Check Slither/Foundry installation and config health
13
+ init Create solidity-argus config file
14
+ install Configure argus plugin in opencode config
15
+ lint-skills Validate SKILL.md files against schema
16
+ check-skills Analyze skills for duplicates, near-duplicates, and conflicts
17
+ `
13
18
 
14
19
  export class CliProgram {
15
- private commands: Map<string, CliCommand> = new Map();
20
+ private commands: Map<string, CliCommand> = new Map()
16
21
 
17
22
  registerCommand(command: CliCommand): void {
18
- this.commands.set(command.name, command);
23
+ this.commands.set(command.name, command)
19
24
  }
20
25
 
21
26
  async dispatch(args: string[]): Promise<number> {
22
- const subcommand = args[0];
27
+ const subcommand = args[0]
23
28
 
24
29
  if (!subcommand || subcommand === "--help" || subcommand === "-h") {
25
- console.log(HELP_TEXT);
26
- return 0;
30
+ cliOutput.log(HELP_TEXT)
31
+ return 0
27
32
  }
28
33
 
29
- const command = this.commands.get(subcommand);
34
+ const command = this.commands.get(subcommand)
30
35
  if (!command) {
31
- console.error(`Error: Unknown command '${subcommand}'. Run 'argus' for help.`);
32
- return 1;
36
+ cliOutput.error(`Unknown command '${subcommand}'. Run 'argus' for help.`)
37
+ return 1
33
38
  }
34
39
 
35
- return command.execute(args.slice(1));
40
+ return command.execute(args.slice(1))
36
41
  }
37
42
  }
38
43
 
39
44
  export function createCliProgram(): CliProgram {
40
- const program = new CliProgram();
41
- program.registerCommand(doctorCommand);
42
- program.registerCommand(initCommand);
43
- program.registerCommand(installCommand);
44
- return program;
45
+ const program = new CliProgram()
46
+ program.registerCommand(doctorCommand)
47
+ program.registerCommand(initCommand)
48
+ program.registerCommand(installCommand)
49
+ program.registerCommand(lintSkillsCommand)
50
+ program.registerCommand(checkSkillsCommand)
51
+ return program
45
52
  }
@@ -0,0 +1,135 @@
1
+ import { readdirSync, readFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { loadArgusConfig } from "../../config/loader"
4
+ import { createLogger } from "../../shared/logger"
5
+ import {
6
+ DEFAULT_GATE_CONFIG,
7
+ formatReportJson,
8
+ formatReportText,
9
+ type GateConfig,
10
+ generateReport,
11
+ type SkillReport,
12
+ } from "../../skills/analysis/gates"
13
+ import { normalizeSkill, type SkillDoc } from "../../skills/analysis/normalize"
14
+ import { buildTfidfCorpus, computeAllPairs } from "../../skills/analysis/similarity"
15
+ import { resolveSkillRoots } from "../../skills/argus-skill-resolver"
16
+ import { cliOutput } from "../cli-output"
17
+ import type { CliCommand } from "../types"
18
+
19
+ const logger = createLogger()
20
+
21
+ function findSkillFiles(dir: string, maxDepth = 8): string[] {
22
+ const files: string[] = []
23
+ const stack: Array<{ path: string; depth: number }> = [{ path: dir, depth: 0 }]
24
+
25
+ while (stack.length > 0) {
26
+ const current = stack.pop()
27
+ if (!current || current.depth > maxDepth) continue
28
+
29
+ try {
30
+ const entries = readdirSync(current.path, { withFileTypes: true })
31
+ for (const entry of entries) {
32
+ const fullPath = join(current.path, entry.name)
33
+ if (entry.isDirectory()) {
34
+ stack.push({ path: fullPath, depth: current.depth + 1 })
35
+ } else if (entry.isFile() && entry.name.toUpperCase() === "SKILL.MD") {
36
+ files.push(fullPath)
37
+ }
38
+ }
39
+ } catch {}
40
+ }
41
+
42
+ return files
43
+ }
44
+
45
+ function parseFormatArg(args: string[]): "text" | "json" {
46
+ const formatIdx = args.indexOf("--format")
47
+ if (formatIdx !== -1 && args[formatIdx + 1] === "json") {
48
+ return "json"
49
+ }
50
+ return "text"
51
+ }
52
+
53
+ function parseThresholdArg(args: string[], flag: string, fallback: number): number {
54
+ const idx = args.indexOf(flag)
55
+ if (idx === -1) return fallback
56
+ const raw = args[idx + 1]
57
+ if (!raw) return fallback
58
+ const parsed = Number.parseFloat(raw)
59
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : fallback
60
+ }
61
+
62
+ export function loadAndNormalizeSkills(cwd: string): SkillDoc[] {
63
+ let config: ReturnType<typeof loadArgusConfig> | undefined
64
+ try {
65
+ config = loadArgusConfig(cwd)
66
+ } catch {
67
+ logger.debug("Config load failed, using defaults")
68
+ }
69
+
70
+ const roots = resolveSkillRoots(cwd, config)
71
+ const docs: SkillDoc[] = []
72
+
73
+ for (const root of roots) {
74
+ const files = findSkillFiles(root.path)
75
+ for (const file of files) {
76
+ try {
77
+ const content = readFileSync(file, "utf8")
78
+ const doc = normalizeSkill(content)
79
+ if (doc) {
80
+ docs.push(doc)
81
+ }
82
+ } catch {
83
+ logger.debug("Skipping unreadable skill file")
84
+ }
85
+ }
86
+ }
87
+
88
+ return docs
89
+ }
90
+
91
+ export function runAnalysis(docs: SkillDoc[], config: GateConfig): SkillReport {
92
+ const corpus = buildTfidfCorpus(docs)
93
+ const pairs = computeAllPairs(docs, corpus)
94
+ return generateReport(docs, pairs, config)
95
+ }
96
+
97
+ export const checkSkillsCommand: CliCommand = {
98
+ name: "check-skills",
99
+ description:
100
+ "Analyze SKILL.md files for duplicates, near-duplicates, and detection rule conflicts",
101
+ async execute(args: string[]): Promise<number> {
102
+ const cwd = process.cwd()
103
+ const format = parseFormatArg(args)
104
+
105
+ const gateConfig: GateConfig = {
106
+ blockThreshold: parseThresholdArg(
107
+ args,
108
+ "--block-threshold",
109
+ DEFAULT_GATE_CONFIG.blockThreshold,
110
+ ),
111
+ warnThreshold: parseThresholdArg(args, "--warn-threshold", DEFAULT_GATE_CONFIG.warnThreshold),
112
+ infoThreshold: parseThresholdArg(args, "--info-threshold", DEFAULT_GATE_CONFIG.infoThreshold),
113
+ blockExactRegexConflict: !args.includes("--no-regex-conflict"),
114
+ }
115
+
116
+ const docs = loadAndNormalizeSkills(cwd)
117
+
118
+ if (docs.length === 0) {
119
+ cliOutput.log("No SKILL.md files found.")
120
+ return 0
121
+ }
122
+
123
+ cliOutput.log(`Analyzing ${docs.length} skills...`)
124
+
125
+ const report = runAnalysis(docs, gateConfig)
126
+
127
+ if (format === "json") {
128
+ cliOutput.log(formatReportJson(report))
129
+ } else {
130
+ cliOutput.log(formatReportText(report))
131
+ }
132
+
133
+ return report.summary.block > 0 ? 1 : 0
134
+ },
135
+ }
@@ -1,8 +1,22 @@
1
- import { execSync } from "node:child_process"
2
- import { existsSync } from "node:fs"
3
- import { join } from "node:path"
4
- import type { CliCommand } from "../types"
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
2
+ import { basename, dirname, extname, join } from "node:path"
5
3
  import { loadArgusConfig } from "../../config/loader"
4
+ import type { ArgusConfig } from "../../config/types"
5
+ import { createLogger } from "../../shared/logger"
6
+ import {
7
+ getRequiredAuditSkills,
8
+ normalizeSkillName,
9
+ type ResolvedSkill,
10
+ resolveArgusSkills,
11
+ resolveSkillRoots,
12
+ } from "../../skills/argus-skill-resolver"
13
+ import { parseFrontmatter, validateSkillFrontmatter } from "../../skills/skill-schema"
14
+ import { detectViaIr } from "../../tools/slither-tool"
15
+ import { checkSoloditHealth } from "../../utils/solodit-health"
16
+ import { cliOutput } from "../cli-output"
17
+ import type { CliCommand } from "../types"
18
+
19
+ const logger = createLogger()
6
20
 
7
21
  const GREEN = "\x1b[32m"
8
22
  const RED = "\x1b[31m"
@@ -11,10 +25,15 @@ const RESET = "\x1b[0m"
11
25
 
12
26
  function checkBinary(name: string): { found: boolean; version: string | null } {
13
27
  try {
14
- const version = execSync(`${name} --version`, { timeout: 5000 })
15
- .toString()
16
- .trim()
17
- .split("\n")[0] ?? null
28
+ const result = Bun.spawnSync([name, "--version"], {
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ timeout: 5000,
32
+ })
33
+ if (result.exitCode !== 0) {
34
+ return { found: false, version: null }
35
+ }
36
+ const version = new TextDecoder().decode(result.stdout).trim().split("\n")[0] ?? null
18
37
  return { found: true, version }
19
38
  } catch {
20
39
  return { found: false, version: null }
@@ -28,54 +47,315 @@ function checkSolidityProject(dir: string): string | null {
28
47
  return null
29
48
  }
30
49
 
50
+ export const ALL_CATEGORIES = [
51
+ "vulnerability-pattern",
52
+ "methodology",
53
+ "protocol-pattern",
54
+ "checklist",
55
+ "reference",
56
+ ] as const
57
+
58
+ export const REQUIRED_CATEGORIES: readonly string[] = ["vulnerability-pattern", "methodology"]
59
+
60
+ export type SkillHealthReport = {
61
+ categoryBreakdown: Record<string, number>
62
+ trustTierBreakdown: Record<string, number>
63
+ duplicates: Array<{ name: string; sources: string[] }>
64
+ schemaValid: number
65
+ schemaInvalid: number
66
+ schemaSkipped: number
67
+ invalidSkills: Array<{ name: string; error: string }>
68
+ missingCategories: string[]
69
+ }
70
+
71
+ export function findDuplicateSkills(
72
+ entries: Array<{ name: string; source: string }>,
73
+ ): Array<{ name: string; sources: string[] }> {
74
+ const nameToSources = new Map<string, Set<string>>()
75
+ for (const { name, source } of entries) {
76
+ if (!nameToSources.has(name)) nameToSources.set(name, new Set())
77
+ const sources = nameToSources.get(name)
78
+ if (sources) sources.add(source)
79
+ }
80
+ return Array.from(nameToSources)
81
+ .filter(([, sources]) => sources.size > 1)
82
+ .map(([name, sources]) => ({ name, sources: Array.from(sources) }))
83
+ }
84
+
85
+ export function buildSkillHealthReport(
86
+ resolvedSkills: Map<string, ResolvedSkill>,
87
+ duplicateEntries?: Array<{ name: string; source: string }>,
88
+ ): SkillHealthReport {
89
+ const categoryBreakdown: Record<string, number> = {}
90
+ for (const cat of ALL_CATEGORIES) categoryBreakdown[cat] = 0
91
+
92
+ const trustTierBreakdown: Record<string, number> = {}
93
+ let schemaValid = 0
94
+ let schemaInvalid = 0
95
+ let schemaSkipped = 0
96
+ const invalidSkills: Array<{ name: string; error: string }> = []
97
+
98
+ for (const [name, skill] of resolvedSkills) {
99
+ trustTierBreakdown[skill.source] = (trustTierBreakdown[skill.source] ?? 0) + 1
100
+
101
+ const fm = parseFrontmatter(skill.content)
102
+ if (fm) {
103
+ const validation = validateSkillFrontmatter(fm)
104
+ if (validation.success) {
105
+ schemaValid++
106
+ if (validation.data.category) {
107
+ categoryBreakdown[validation.data.category] =
108
+ (categoryBreakdown[validation.data.category] ?? 0) + 1
109
+ }
110
+ } else {
111
+ schemaInvalid++
112
+ invalidSkills.push({ name, error: validation.errors[0] ?? "unknown error" })
113
+ }
114
+ } else {
115
+ schemaSkipped++
116
+ }
117
+ }
118
+
119
+ const duplicates = duplicateEntries ? findDuplicateSkills(duplicateEntries) : []
120
+ const missingCategories = REQUIRED_CATEGORIES.filter((cat) => (categoryBreakdown[cat] ?? 0) === 0)
121
+
122
+ return {
123
+ categoryBreakdown,
124
+ trustTierBreakdown,
125
+ duplicates,
126
+ schemaValid,
127
+ schemaInvalid,
128
+ schemaSkipped,
129
+ invalidSkills,
130
+ missingCategories,
131
+ }
132
+ }
133
+
134
+ function scanMarkdownFiles(dir: string, maxDepth = 8): string[] {
135
+ if (!existsSync(dir)) return []
136
+ const files: string[] = []
137
+ const stack: Array<{ path: string; depth: number }> = [{ path: dir, depth: 0 }]
138
+ while (stack.length > 0) {
139
+ const current = stack.pop()
140
+ if (!current || current.depth > maxDepth) continue
141
+ try {
142
+ const entries = readdirSync(current.path, { withFileTypes: true })
143
+ for (const entry of entries) {
144
+ const fullPath = join(current.path, entry.name)
145
+ if (entry.isDirectory()) {
146
+ stack.push({ path: fullPath, depth: current.depth + 1 })
147
+ } else if (entry.isFile() && extname(entry.name).toLowerCase() === ".md") {
148
+ files.push(fullPath)
149
+ }
150
+ }
151
+ } catch {
152
+ logger.debug("Failed to read directory during skill scan")
153
+ }
154
+ }
155
+ return files
156
+ }
157
+
158
+ function inferSkillName(filePath: string): string {
159
+ if (basename(filePath) === "SKILL.md") {
160
+ return basename(dirname(filePath))
161
+ }
162
+ return basename(filePath, extname(filePath))
163
+ }
164
+
165
+ function collectAllSkillNames(
166
+ projectDir: string,
167
+ argusConfig?: ArgusConfig,
168
+ ): Array<{ name: string; source: string }> {
169
+ const roots = resolveSkillRoots(projectDir, argusConfig)
170
+ const entries: Array<{ name: string; source: string }> = []
171
+ for (const root of roots) {
172
+ const files = scanMarkdownFiles(root.path)
173
+ for (const file of files) {
174
+ try {
175
+ const content = readFileSync(file, "utf8")
176
+ const fm = parseFrontmatter(content)
177
+ const nameFromFm = typeof fm?.name === "string" ? fm.name : null
178
+ const rawName = nameFromFm || inferSkillName(file)
179
+ const name = normalizeSkillName(rawName)
180
+ if (name) entries.push({ name, source: root.source })
181
+ } catch {
182
+ logger.debug("Failed to parse skill file frontmatter")
183
+ }
184
+ }
185
+ }
186
+ return entries
187
+ }
188
+
31
189
  export const doctorCommand: CliCommand = {
32
190
  name: "doctor",
33
191
  description: "Check tool dependencies and configuration",
34
- async execute(args: string[]): Promise<number> {
192
+ async execute(_args: string[]): Promise<number> {
35
193
  const cwd = process.cwd()
36
194
  let hasFailure = false
37
195
 
38
- console.log("Argus Doctor\n")
196
+ cliOutput.log("Argus Doctor\n")
39
197
 
40
198
  const slither = checkBinary("slither")
41
199
  if (slither.found) {
42
- console.log(`${GREEN}✓${RESET} Slither: installed (${slither.version})`)
200
+ cliOutput.log(`${GREEN}✓${RESET} Slither: installed (${slither.version})`)
43
201
  } else {
44
- console.log(`${RED}✗${RESET} Slither: not found — pip install slither-analyzer`)
202
+ cliOutput.log(`${RED}✗${RESET} Slither: not found — pip install slither-analyzer`)
45
203
  hasFailure = true
46
204
  }
47
205
 
48
206
  const forge = checkBinary("forge")
49
207
  if (forge.found) {
50
- console.log(`${GREEN}✓${RESET} Forge: installed (${forge.version})`)
208
+ cliOutput.log(`${GREEN}✓${RESET} Forge: installed (${forge.version})`)
51
209
  } else {
52
- console.log(`${RED}✗${RESET} Forge: not found — curl -L https://foundry.paradigm.xyz | bash`)
210
+ cliOutput.log(
211
+ `${RED}✗${RESET} Forge: not found — curl -L https://foundry.paradigm.xyz | bash`,
212
+ )
53
213
  hasFailure = true
54
214
  }
55
215
 
216
+ const solcSelect = checkBinary("solc-select")
217
+ if (solcSelect.found) {
218
+ cliOutput.log(`${GREEN}✓${RESET} solc-select: installed (${solcSelect.version})`)
219
+ } else {
220
+ cliOutput.log(
221
+ `${YELLOW}⚠${RESET} solc-select: not found — pipx install solc-select (needed for via_ir flatten fallback)`,
222
+ )
223
+ }
224
+
56
225
  const projectType = checkSolidityProject(cwd)
57
226
  if (projectType) {
58
- console.log(`${GREEN}✓${RESET} Project: ${projectType} detected`)
227
+ cliOutput.log(`${GREEN}✓${RESET} Project: ${projectType} detected`)
59
228
  } else {
60
- console.log(`${YELLOW}⚠${RESET} Project: no Solidity project detected`)
229
+ cliOutput.log(`${YELLOW}⚠${RESET} Project: no Solidity project detected`)
230
+ }
231
+
232
+ if (projectType === "foundry" && detectViaIr(cwd)) {
233
+ cliOutput.log(
234
+ `${YELLOW}⚠${RESET} via_ir: enabled in foundry.toml — Slither will use flatten fallback`,
235
+ )
236
+ if (!forge.found) {
237
+ cliOutput.log(
238
+ `${RED}✗${RESET} forge is required for via_ir flatten fallback but is missing`,
239
+ )
240
+ hasFailure = true
241
+ }
242
+ if (!solcSelect.found) {
243
+ cliOutput.log(`${YELLOW}⚠${RESET} solc-select is recommended for via_ir flatten fallback`)
244
+ }
61
245
  }
62
246
 
247
+ let config: ReturnType<typeof loadArgusConfig> | undefined
63
248
  try {
64
- const config = loadArgusConfig(cwd)
65
- console.log(`${GREEN}✓${RESET} Config: valid`)
249
+ config = loadArgusConfig(cwd)
250
+ cliOutput.log(`${GREEN}✓${RESET} Config: valid`)
251
+
252
+ const requiredSkills = getRequiredAuditSkills()
253
+ const resolvedSkills = resolveArgusSkills(cwd, config)
254
+ const missingSkills = requiredSkills.filter((skillName) => !resolvedSkills.has(skillName))
255
+
256
+ if (missingSkills.length === 0) {
257
+ cliOutput.log(
258
+ `${GREEN}✓${RESET} Skills: required audit skills resolvable (${requiredSkills.join(", ")})`,
259
+ )
260
+ } else {
261
+ cliOutput.log(
262
+ `${RED}✗${RESET} Skills: missing required skills (${missingSkills.join(", ")})`,
263
+ )
264
+ hasFailure = true
265
+ }
66
266
  } catch {
67
- console.log(`${YELLOW}⚠${RESET} Config: using defaults`)
267
+ cliOutput.log(`${YELLOW}⚠${RESET} Config: using defaults`)
268
+
269
+ const requiredSkills = getRequiredAuditSkills()
270
+ const resolvedSkills = resolveArgusSkills(cwd)
271
+ const missingSkills = requiredSkills.filter((skillName) => !resolvedSkills.has(skillName))
272
+
273
+ if (missingSkills.length === 0) {
274
+ cliOutput.log(
275
+ `${GREEN}✓${RESET} Skills: required audit skills resolvable (${requiredSkills.join(", ")})`,
276
+ )
277
+ } else {
278
+ cliOutput.log(
279
+ `${RED}✗${RESET} Skills: missing required skills (${missingSkills.join(", ")})`,
280
+ )
281
+ hasFailure = true
282
+ }
68
283
  }
69
284
 
70
285
  try {
71
- const response = await fetch("https://api.scvd.dev/stats", { signal: AbortSignal.timeout(5000) })
286
+ const response = await fetch("https://api.scvd.dev/stats", {
287
+ signal: AbortSignal.timeout(5000),
288
+ })
72
289
  if (response.ok) {
73
- console.log(`${GREEN}✓${RESET} SCVD API: reachable`)
290
+ cliOutput.log(`${GREEN}✓${RESET} SCVD API: reachable`)
74
291
  } else {
75
- console.log(`${YELLOW}⚠${RESET} SCVD API: returned ${response.status}`)
292
+ cliOutput.log(`${YELLOW}⚠${RESET} SCVD API: returned ${response.status}`)
76
293
  }
77
294
  } catch {
78
- console.log(`${YELLOW}⚠${RESET} SCVD API: unreachable`)
295
+ cliOutput.log(`${YELLOW}⚠${RESET} SCVD API: unreachable`)
296
+ }
297
+
298
+ // Solodit MCP check
299
+ const soloditConfig = config?.solodit ?? { enabled: true, port: 3000 }
300
+ const soloditEnabled = soloditConfig.enabled !== false
301
+ const soloditPort = soloditConfig.port ?? 3000
302
+
303
+ if (soloditEnabled) {
304
+ const health = await checkSoloditHealth(soloditPort, true)
305
+ if (health.reachable) {
306
+ cliOutput.log(`${GREEN}✓${RESET} Solodit MCP: reachable on port ${soloditPort}`)
307
+ } else {
308
+ cliOutput.log(
309
+ `${YELLOW}⚠${RESET} Solodit MCP: unreachable on port ${soloditPort} (start with: npx @lyuboslavlyubenov/solodit-mcp)`,
310
+ )
311
+ }
312
+ } else {
313
+ cliOutput.log(`${YELLOW}⚠${RESET} Solodit MCP: disabled in config`)
314
+ }
315
+
316
+ cliOutput.log("\nSkill Health")
317
+ try {
318
+ const healthSkills = resolveArgusSkills(cwd, config)
319
+ const allEntries = collectAllSkillNames(cwd, config)
320
+ const report = buildSkillHealthReport(healthSkills, allEntries)
321
+
322
+ const catParts = ALL_CATEGORIES.map((cat) => `${cat}: ${report.categoryBreakdown[cat] ?? 0}`)
323
+ cliOutput.log(`${GREEN}✓${RESET} Categories: ${catParts.join(", ")}`)
324
+
325
+ const tierParts = Object.entries(report.trustTierBreakdown).map(
326
+ ([tier, count]) => `${tier}: ${count}`,
327
+ )
328
+ cliOutput.log(`${GREEN}✓${RESET} Trust tiers: ${tierParts.join(", ")}`)
329
+
330
+ if (report.schemaInvalid === 0) {
331
+ cliOutput.log(
332
+ `${GREEN}✓${RESET} Schema: ${report.schemaValid} valid, 0 invalid, ${report.schemaSkipped} skipped (no frontmatter)`,
333
+ )
334
+ } else {
335
+ cliOutput.log(
336
+ `${YELLOW}⚠${RESET} Schema: ${report.schemaValid} valid, ${report.schemaInvalid} invalid, ${report.schemaSkipped} skipped (no frontmatter)`,
337
+ )
338
+ for (const inv of report.invalidSkills) {
339
+ cliOutput.log(` ${RED}✗${RESET} ${inv.name}: ${inv.error}`)
340
+ }
341
+ }
342
+
343
+ if (report.duplicates.length > 0) {
344
+ for (const dup of report.duplicates) {
345
+ cliOutput.log(
346
+ `${YELLOW}⚠${RESET} Duplicate skill: "${dup.name}" found in ${dup.sources.join(" and ")}`,
347
+ )
348
+ }
349
+ } else {
350
+ cliOutput.log(`${GREEN}✓${RESET} No duplicate skills detected`)
351
+ }
352
+
353
+ for (const cat of report.missingCategories) {
354
+ cliOutput.log(`${YELLOW}⚠${RESET} Required category "${cat}" has 0 skills`)
355
+ }
356
+ } catch {
357
+ cliOutput.log(`${RED}✗${RESET} Could not analyze skill health`)
358
+ hasFailure = true
79
359
  }
80
360
 
81
361
  return hasFailure ? 1 : 0
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
+ import { cliOutput } from "../cli-output"
3
4
  import type { CliCommand } from "../types"
4
5
 
5
6
  const GREEN = "\x1b[32m"
@@ -17,14 +18,15 @@ const DEFAULT_CONFIG = {
17
18
  export const initCommand: CliCommand = {
18
19
  name: "init",
19
20
  description: "Initialize Argus configuration for this project",
20
- async execute(args: string[]): Promise<number> {
21
+ async execute(_args: string[]): Promise<number> {
21
22
  const cwd = process.cwd()
22
23
  const configDir = join(cwd, ".opencode")
23
24
  const configPath = join(configDir, "solidity-argus.json")
24
25
 
25
26
  if (existsSync(configPath)) {
26
- console.error(`${YELLOW}⚠${RESET} Config already exists: ${configPath}`)
27
- console.error(" Remove it first if you want to reinitialize.")
27
+ cliOutput.error(
28
+ `${YELLOW}⚠${RESET} Config already exists: ${configPath} — remove it first if you want to reinitialize.`,
29
+ )
28
30
  return 1
29
31
  }
30
32
 
@@ -37,9 +39,9 @@ export const initCommand: CliCommand = {
37
39
  ? "Hardhat"
38
40
  : "unknown"
39
41
 
40
- console.log(`${GREEN}✓${RESET} Created ${configPath}`)
41
- console.log(` Project type: ${projectType}`)
42
- console.log(" Run 'argus doctor' to check dependencies.")
42
+ cliOutput.log(`${GREEN}✓${RESET} Created ${configPath}`)
43
+ cliOutput.log(` Project type: ${projectType}`)
44
+ cliOutput.log(" Run 'argus doctor' to check dependencies.")
43
45
 
44
46
  return 0
45
47
  },
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
- import { join } from "node:path"
3
2
  import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { cliOutput } from "../cli-output"
4
5
  import type { CliCommand } from "../types"
5
6
 
6
7
  const GREEN = "\x1b[32m"
@@ -22,12 +23,13 @@ export function findOpencodeConfig(homeOverride?: string): string | null {
22
23
  export const installCommand: CliCommand = {
23
24
  name: "install",
24
25
  description: "Register solidity-argus in your OpenCode config",
25
- async execute(args: string[]): Promise<number> {
26
+ async execute(_args: string[]): Promise<number> {
26
27
  const configPath = findOpencodeConfig()
27
28
 
28
29
  if (!configPath) {
29
- console.error(`${YELLOW}⚠${RESET} opencode.json not found`)
30
- console.error(" Create one first, or run: opencode init")
30
+ cliOutput.error(
31
+ `${YELLOW}⚠${RESET} opencode.json not found — create one first, or run: opencode init`,
32
+ )
31
33
  return 1
32
34
  }
33
35
 
@@ -37,7 +39,7 @@ export const installCommand: CliCommand = {
37
39
  const plugins: string[] = config.plugin ?? []
38
40
 
39
41
  if (plugins.includes("solidity-argus")) {
40
- console.log(`${GREEN}✓${RESET} solidity-argus already registered in ${configPath}`)
42
+ cliOutput.log(`${GREEN}✓${RESET} solidity-argus already registered in ${configPath}`)
41
43
  return 0
42
44
  }
43
45
 
@@ -45,10 +47,10 @@ export const installCommand: CliCommand = {
45
47
  config.plugin = plugins
46
48
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
47
49
 
48
- console.log(`${GREEN}✓${RESET} Added solidity-argus to ${configPath}`)
50
+ cliOutput.log(`${GREEN}✓${RESET} Added solidity-argus to ${configPath}`)
49
51
  return 0
50
- } catch (error) {
51
- console.error(`${YELLOW}⚠${RESET} Failed to update ${configPath}`)
52
+ } catch (_error) {
53
+ cliOutput.error(`${YELLOW}⚠${RESET} Failed to update ${configPath}`)
52
54
  return 1
53
55
  }
54
56
  },