solidity-argus 0.1.8 → 0.2.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 (84) hide show
  1. package/README.md +161 -1
  2. package/package.json +5 -2
  3. package/skills/README.md +63 -0
  4. package/skills/checklists/cyfrin-defi-core/SKILL.md +3 -0
  5. package/skills/manifests/cyfrin.json +16 -0
  6. package/skills/manifests/defifofum.json +25 -0
  7. package/skills/manifests/kadenzipfel.json +48 -0
  8. package/skills/manifests/scvd.json +9 -0
  9. package/skills/manifests/smartbugs.json +11 -0
  10. package/skills/manifests/solodit.json +9 -0
  11. package/skills/manifests/sunweb3sec.json +11 -0
  12. package/skills/manifests/trailofbits.json +9 -0
  13. package/skills/methodology/audit-workflow/SKILL.md +3 -0
  14. package/skills/patterns/access-control.yaml +31 -0
  15. package/skills/patterns/erc4626.yaml +29 -0
  16. package/skills/patterns/flash-loan.yaml +20 -0
  17. package/skills/patterns/oracle.yaml +30 -0
  18. package/skills/patterns/proxy.yaml +30 -0
  19. package/skills/patterns/reentrancy.yaml +30 -0
  20. package/skills/patterns/signature.yaml +31 -0
  21. package/skills/protocol-patterns/amm-dex/SKILL.md +3 -0
  22. package/skills/references/exploit-reference/SKILL.md +3 -0
  23. package/skills/vulnerability-patterns/access-control/SKILL.md +13 -0
  24. package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +6 -0
  25. package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +6 -0
  26. package/skills/vulnerability-patterns/dos-revert/SKILL.md +13 -1
  27. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +12 -0
  28. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +13 -0
  29. package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +10 -1
  30. package/skills/vulnerability-patterns/reentrancy/SKILL.md +13 -0
  31. package/skills/vulnerability-patterns/signature-malleability/SKILL.md +9 -0
  32. package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +11 -0
  33. package/src/agents/argus-prompt.ts +4 -4
  34. package/src/agents/pythia-prompt.ts +4 -4
  35. package/src/agents/scribe-prompt.ts +3 -3
  36. package/src/agents/sentinel-prompt.ts +4 -4
  37. package/src/cli/cli-output.ts +16 -0
  38. package/src/cli/cli-program.ts +9 -5
  39. package/src/cli/commands/doctor.ts +274 -16
  40. package/src/cli/commands/init.ts +5 -5
  41. package/src/cli/commands/install.ts +5 -5
  42. package/src/cli/commands/lint-skills.ts +114 -0
  43. package/src/cli/tui-prompts.ts +4 -2
  44. package/src/config/schema.ts +2 -0
  45. package/src/create-hooks.ts +99 -14
  46. package/src/create-tools.ts +2 -0
  47. package/src/features/error-recovery/tool-error-recovery.ts +74 -19
  48. package/src/features/persistent-state/audit-state-manager.ts +36 -13
  49. package/src/hooks/agent-tracker.ts +53 -0
  50. package/src/hooks/compaction-hook.ts +46 -37
  51. package/src/hooks/config-handler.ts +3 -0
  52. package/src/hooks/context-budget.ts +45 -0
  53. package/src/hooks/event-hook.ts +5 -4
  54. package/src/hooks/knowledge-sync-hook.ts +2 -1
  55. package/src/hooks/recon-context-builder.ts +66 -0
  56. package/src/hooks/safe-create-hook.ts +4 -5
  57. package/src/hooks/system-prompt-hook.ts +128 -0
  58. package/src/hooks/tool-tracking-hook.ts +86 -7
  59. package/src/index.ts +24 -1
  60. package/src/knowledge/retry.ts +53 -0
  61. package/src/knowledge/scvd-client.ts +37 -10
  62. package/src/knowledge/scvd-errors.ts +89 -0
  63. package/src/knowledge/scvd-index.ts +53 -3
  64. package/src/knowledge/scvd-sync.ts +205 -34
  65. package/src/knowledge/source-manifest.ts +102 -0
  66. package/src/plugin-interface.ts +14 -1
  67. package/src/shared/binary-utils.ts +1 -0
  68. package/src/shared/logger.ts +78 -17
  69. package/src/skills/argus-skill-resolver.ts +226 -0
  70. package/src/skills/skill-schema.ts +98 -0
  71. package/src/state/audit-state.ts +2 -0
  72. package/src/state/types.ts +32 -1
  73. package/src/tools/argus-skill-load-tool.ts +73 -0
  74. package/src/tools/pattern-checker-tool.ts +56 -12
  75. package/src/tools/pattern-loader.ts +183 -0
  76. package/src/tools/pattern-schema.ts +51 -0
  77. package/src/tools/report-generator-tool.ts +134 -11
  78. package/src/tools/slither-tool.ts +61 -19
  79. package/src/tools/solodit-search-tool.ts +92 -14
  80. package/src/utils/audit-artifact-detector.ts +119 -0
  81. package/src/utils/dependency-scanner.ts +93 -0
  82. package/src/utils/project-detector.ts +128 -26
  83. package/src/utils/solidity-parser.ts +20 -4
  84. package/src/utils/solodit-health.ts +29 -0
@@ -1,8 +1,20 @@
1
1
  import { execSync } from "node:child_process"
2
- import { existsSync } from "node:fs"
3
- import { join } from "node:path"
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
3
+ import { basename, dirname, extname, join } from "node:path"
4
4
  import type { CliCommand } from "../types"
5
+ import type { ArgusConfig } from "../../config/types"
5
6
  import { loadArgusConfig } from "../../config/loader"
7
+ import {
8
+ getRequiredAuditSkills,
9
+ normalizeSkillName,
10
+ resolveArgusSkills,
11
+ resolveSkillRoots,
12
+ type ResolvedSkill,
13
+ } from "../../skills/argus-skill-resolver"
14
+ import { parseFrontmatter, validateSkillFrontmatter } from "../../skills/skill-schema"
15
+ import { detectViaIr } from "../../tools/slither-tool"
16
+ import { checkSoloditHealth } from "../../utils/solodit-health"
17
+ import { cliOutput } from "../cli-output"
6
18
 
7
19
  const GREEN = "\x1b[32m"
8
20
  const RED = "\x1b[31m"
@@ -11,7 +23,10 @@ const RESET = "\x1b[0m"
11
23
 
12
24
  function checkBinary(name: string): { found: boolean; version: string | null } {
13
25
  try {
14
- const version = execSync(`${name} --version`, { timeout: 5000 })
26
+ const version = execSync(`${name} --version`, {
27
+ timeout: 5000,
28
+ stdio: ["pipe", "pipe", "pipe"],
29
+ })
15
30
  .toString()
16
31
  .trim()
17
32
  .split("\n")[0] ?? null
@@ -28,6 +43,144 @@ function checkSolidityProject(dir: string): string | null {
28
43
  return null
29
44
  }
30
45
 
46
+ export const ALL_CATEGORIES = [
47
+ "vulnerability-pattern",
48
+ "methodology",
49
+ "protocol-pattern",
50
+ "checklist",
51
+ "reference",
52
+ ] as const
53
+
54
+ export const REQUIRED_CATEGORIES: readonly string[] = ["vulnerability-pattern", "methodology"]
55
+
56
+ export type SkillHealthReport = {
57
+ categoryBreakdown: Record<string, number>
58
+ trustTierBreakdown: Record<string, number>
59
+ duplicates: Array<{ name: string; sources: string[] }>
60
+ schemaValid: number
61
+ schemaInvalid: number
62
+ schemaSkipped: number
63
+ invalidSkills: Array<{ name: string; error: string }>
64
+ missingCategories: string[]
65
+ }
66
+
67
+ export function findDuplicateSkills(
68
+ entries: Array<{ name: string; source: string }>,
69
+ ): Array<{ name: string; sources: string[] }> {
70
+ const nameToSources = new Map<string, Set<string>>()
71
+ for (const { name, source } of entries) {
72
+ if (!nameToSources.has(name)) nameToSources.set(name, new Set())
73
+ nameToSources.get(name)!.add(source)
74
+ }
75
+ return Array.from(nameToSources)
76
+ .filter(([, sources]) => sources.size > 1)
77
+ .map(([name, sources]) => ({ name, sources: Array.from(sources) }))
78
+ }
79
+
80
+ export function buildSkillHealthReport(
81
+ resolvedSkills: Map<string, ResolvedSkill>,
82
+ duplicateEntries?: Array<{ name: string; source: string }>,
83
+ ): SkillHealthReport {
84
+ const categoryBreakdown: Record<string, number> = {}
85
+ for (const cat of ALL_CATEGORIES) categoryBreakdown[cat] = 0
86
+
87
+ const trustTierBreakdown: Record<string, number> = {}
88
+ let schemaValid = 0
89
+ let schemaInvalid = 0
90
+ let schemaSkipped = 0
91
+ const invalidSkills: Array<{ name: string; error: string }> = []
92
+
93
+ for (const [name, skill] of resolvedSkills) {
94
+ trustTierBreakdown[skill.source] = (trustTierBreakdown[skill.source] ?? 0) + 1
95
+
96
+ const fm = parseFrontmatter(skill.content)
97
+ if (fm) {
98
+ const validation = validateSkillFrontmatter(fm)
99
+ if (validation.success) {
100
+ schemaValid++
101
+ if (validation.data.category) {
102
+ categoryBreakdown[validation.data.category] =
103
+ (categoryBreakdown[validation.data.category] ?? 0) + 1
104
+ }
105
+ } else {
106
+ schemaInvalid++
107
+ invalidSkills.push({ name, error: validation.errors[0] ?? "unknown error" })
108
+ }
109
+ } else {
110
+ schemaSkipped++
111
+ }
112
+ }
113
+
114
+ const duplicates = duplicateEntries ? findDuplicateSkills(duplicateEntries) : []
115
+ const missingCategories = REQUIRED_CATEGORIES.filter(
116
+ (cat) => (categoryBreakdown[cat] ?? 0) === 0,
117
+ )
118
+
119
+ return {
120
+ categoryBreakdown,
121
+ trustTierBreakdown,
122
+ duplicates,
123
+ schemaValid,
124
+ schemaInvalid,
125
+ schemaSkipped,
126
+ invalidSkills,
127
+ missingCategories,
128
+ }
129
+ }
130
+
131
+ function scanMarkdownFiles(dir: string, maxDepth = 8): string[] {
132
+ if (!existsSync(dir)) return []
133
+ const files: string[] = []
134
+ const stack: Array<{ path: string; depth: number }> = [{ path: dir, depth: 0 }]
135
+ while (stack.length > 0) {
136
+ const current = stack.pop()
137
+ if (!current || current.depth > maxDepth) continue
138
+ try {
139
+ const entries = readdirSync(current.path, { withFileTypes: true })
140
+ for (const entry of entries) {
141
+ const fullPath = join(current.path, entry.name)
142
+ if (entry.isDirectory()) {
143
+ stack.push({ path: fullPath, depth: current.depth + 1 })
144
+ } else if (entry.isFile() && extname(entry.name).toLowerCase() === ".md") {
145
+ files.push(fullPath)
146
+ }
147
+ }
148
+ } catch {
149
+ }
150
+ }
151
+ return files
152
+ }
153
+
154
+ function inferSkillName(filePath: string): string {
155
+ if (basename(filePath) === "SKILL.md") {
156
+ return basename(dirname(filePath))
157
+ }
158
+ return basename(filePath, extname(filePath))
159
+ }
160
+
161
+ function collectAllSkillNames(
162
+ projectDir: string,
163
+ argusConfig?: ArgusConfig,
164
+ ): Array<{ name: string; source: string }> {
165
+ const roots = resolveSkillRoots(projectDir, argusConfig)
166
+ const entries: Array<{ name: string; source: string }> = []
167
+ for (const root of roots) {
168
+ const files = scanMarkdownFiles(root.path)
169
+ for (const file of files) {
170
+ try {
171
+ const content = readFileSync(file, "utf8")
172
+ const fm = parseFrontmatter(content)
173
+ const nameFromFm = typeof fm?.name === "string" ? fm.name : null
174
+ const rawName = nameFromFm || inferSkillName(file)
175
+ const name = normalizeSkillName(rawName)
176
+ if (name) entries.push({ name, source: root.source })
177
+ } catch {
178
+ }
179
+ }
180
+ }
181
+ return entries
182
+ }
183
+
31
184
  export const doctorCommand: CliCommand = {
32
185
  name: "doctor",
33
186
  description: "Check tool dependencies and configuration",
@@ -35,47 +188,152 @@ export const doctorCommand: CliCommand = {
35
188
  const cwd = process.cwd()
36
189
  let hasFailure = false
37
190
 
38
- console.log("Argus Doctor\n")
191
+ cliOutput.log("Argus Doctor\n")
39
192
 
40
193
  const slither = checkBinary("slither")
41
194
  if (slither.found) {
42
- console.log(`${GREEN}✓${RESET} Slither: installed (${slither.version})`)
195
+ cliOutput.log(`${GREEN}✓${RESET} Slither: installed (${slither.version})`)
43
196
  } else {
44
- console.log(`${RED}✗${RESET} Slither: not found — pip install slither-analyzer`)
197
+ cliOutput.log(`${RED}✗${RESET} Slither: not found — pip install slither-analyzer`)
45
198
  hasFailure = true
46
199
  }
47
200
 
48
201
  const forge = checkBinary("forge")
49
202
  if (forge.found) {
50
- console.log(`${GREEN}✓${RESET} Forge: installed (${forge.version})`)
203
+ cliOutput.log(`${GREEN}✓${RESET} Forge: installed (${forge.version})`)
51
204
  } else {
52
- console.log(`${RED}✗${RESET} Forge: not found — curl -L https://foundry.paradigm.xyz | bash`)
205
+ cliOutput.log(`${RED}✗${RESET} Forge: not found — curl -L https://foundry.paradigm.xyz | bash`)
53
206
  hasFailure = true
54
207
  }
55
208
 
209
+ const solcSelect = checkBinary("solc-select")
210
+ if (solcSelect.found) {
211
+ cliOutput.log(`${GREEN}✓${RESET} solc-select: installed (${solcSelect.version})`)
212
+ } else {
213
+ cliOutput.log(`${YELLOW}⚠${RESET} solc-select: not found — pipx install solc-select (needed for via_ir flatten fallback)`)
214
+ }
215
+
56
216
  const projectType = checkSolidityProject(cwd)
57
217
  if (projectType) {
58
- console.log(`${GREEN}✓${RESET} Project: ${projectType} detected`)
218
+ cliOutput.log(`${GREEN}✓${RESET} Project: ${projectType} detected`)
59
219
  } else {
60
- console.log(`${YELLOW}⚠${RESET} Project: no Solidity project detected`)
220
+ cliOutput.log(`${YELLOW}⚠${RESET} Project: no Solidity project detected`)
61
221
  }
62
222
 
223
+ if (projectType === "foundry" && detectViaIr(cwd)) {
224
+ cliOutput.log(`${YELLOW}⚠${RESET} via_ir: enabled in foundry.toml — Slither will use flatten fallback`)
225
+ if (!forge.found) {
226
+ cliOutput.log(`${RED}✗${RESET} forge is required for via_ir flatten fallback but is missing`)
227
+ hasFailure = true
228
+ }
229
+ if (!solcSelect.found) {
230
+ cliOutput.log(`${YELLOW}⚠${RESET} solc-select is recommended for via_ir flatten fallback`)
231
+ }
232
+ }
233
+
234
+ let config: ReturnType<typeof loadArgusConfig> | undefined
63
235
  try {
64
- const config = loadArgusConfig(cwd)
65
- console.log(`${GREEN}✓${RESET} Config: valid`)
236
+ config = loadArgusConfig(cwd)
237
+ cliOutput.log(`${GREEN}✓${RESET} Config: valid`)
238
+
239
+ const requiredSkills = getRequiredAuditSkills()
240
+ const resolvedSkills = resolveArgusSkills(cwd, config)
241
+ const missingSkills = requiredSkills.filter((skillName) => !resolvedSkills.has(skillName))
242
+
243
+ if (missingSkills.length === 0) {
244
+ cliOutput.log(`${GREEN}✓${RESET} Skills: required audit skills resolvable (${requiredSkills.join(", ")})`)
245
+ } else {
246
+ cliOutput.log(`${RED}✗${RESET} Skills: missing required skills (${missingSkills.join(", ")})`)
247
+ hasFailure = true
248
+ }
66
249
  } catch {
67
- console.log(`${YELLOW}⚠${RESET} Config: using defaults`)
250
+ cliOutput.log(`${YELLOW}⚠${RESET} Config: using defaults`)
251
+
252
+ const requiredSkills = getRequiredAuditSkills()
253
+ const resolvedSkills = resolveArgusSkills(cwd)
254
+ const missingSkills = requiredSkills.filter((skillName) => !resolvedSkills.has(skillName))
255
+
256
+ if (missingSkills.length === 0) {
257
+ cliOutput.log(`${GREEN}✓${RESET} Skills: required audit skills resolvable (${requiredSkills.join(", ")})`)
258
+ } else {
259
+ cliOutput.log(`${RED}✗${RESET} Skills: missing required skills (${missingSkills.join(", ")})`)
260
+ hasFailure = true
261
+ }
68
262
  }
69
263
 
70
264
  try {
71
265
  const response = await fetch("https://api.scvd.dev/stats", { signal: AbortSignal.timeout(5000) })
72
266
  if (response.ok) {
73
- console.log(`${GREEN}✓${RESET} SCVD API: reachable`)
267
+ cliOutput.log(`${GREEN}✓${RESET} SCVD API: reachable`)
74
268
  } else {
75
- console.log(`${YELLOW}⚠${RESET} SCVD API: returned ${response.status}`)
269
+ cliOutput.log(`${YELLOW}⚠${RESET} SCVD API: returned ${response.status}`)
270
+ }
271
+ } catch {
272
+ cliOutput.log(`${YELLOW}⚠${RESET} SCVD API: unreachable`)
273
+ }
274
+
275
+ // Solodit MCP check
276
+ const soloditConfig = config?.solodit ?? { enabled: true, port: 3000 }
277
+ const soloditEnabled = soloditConfig.enabled !== false
278
+ const soloditPort = soloditConfig.port ?? 3000
279
+
280
+ if (soloditEnabled) {
281
+ const health = await checkSoloditHealth(soloditPort, true)
282
+ if (health.reachable) {
283
+ cliOutput.log(`${GREEN}✓${RESET} Solodit MCP: reachable on port ${soloditPort}`)
284
+ } else {
285
+ cliOutput.log(
286
+ `${YELLOW}⚠${RESET} Solodit MCP: unreachable on port ${soloditPort} (start with: npx @lyuboslavlyubenov/solodit-mcp)`,
287
+ )
288
+ }
289
+ } else {
290
+ cliOutput.log(`${YELLOW}⚠${RESET} Solodit MCP: disabled in config`)
291
+ }
292
+
293
+ cliOutput.log("\nSkill Health")
294
+ try {
295
+ const healthSkills = resolveArgusSkills(cwd, config)
296
+ const allEntries = collectAllSkillNames(cwd, config)
297
+ const report = buildSkillHealthReport(healthSkills, allEntries)
298
+
299
+ const catParts = ALL_CATEGORIES.map(
300
+ (cat) => `${cat}: ${report.categoryBreakdown[cat] ?? 0}`,
301
+ )
302
+ cliOutput.log(`${GREEN}✓${RESET} Categories: ${catParts.join(", ")}`)
303
+
304
+ const tierParts = Object.entries(report.trustTierBreakdown).map(
305
+ ([tier, count]) => `${tier}: ${count}`,
306
+ )
307
+ cliOutput.log(`${GREEN}✓${RESET} Trust tiers: ${tierParts.join(", ")}`)
308
+
309
+ if (report.schemaInvalid === 0) {
310
+ cliOutput.log(
311
+ `${GREEN}✓${RESET} Schema: ${report.schemaValid} valid, 0 invalid, ${report.schemaSkipped} skipped (no frontmatter)`,
312
+ )
313
+ } else {
314
+ cliOutput.log(
315
+ `${YELLOW}⚠${RESET} Schema: ${report.schemaValid} valid, ${report.schemaInvalid} invalid, ${report.schemaSkipped} skipped (no frontmatter)`,
316
+ )
317
+ for (const inv of report.invalidSkills) {
318
+ cliOutput.log(` ${RED}✗${RESET} ${inv.name}: ${inv.error}`)
319
+ }
320
+ }
321
+
322
+ if (report.duplicates.length > 0) {
323
+ for (const dup of report.duplicates) {
324
+ cliOutput.log(
325
+ `${YELLOW}⚠${RESET} Duplicate skill: "${dup.name}" found in ${dup.sources.join(" and ")}`,
326
+ )
327
+ }
328
+ } else {
329
+ cliOutput.log(`${GREEN}✓${RESET} No duplicate skills detected`)
330
+ }
331
+
332
+ for (const cat of report.missingCategories) {
333
+ cliOutput.log(`${YELLOW}⚠${RESET} Required category "${cat}" has 0 skills`)
76
334
  }
77
335
  } catch {
78
- console.log(`${YELLOW}⚠${RESET} SCVD API: unreachable`)
336
+ cliOutput.log(`${RED}✗${RESET} Could not analyze skill health`)
79
337
  }
80
338
 
81
339
  return hasFailure ? 1 : 0
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
  import type { CliCommand } from "../types"
4
+ import { cliOutput } from "../cli-output"
4
5
 
5
6
  const GREEN = "\x1b[32m"
6
7
  const YELLOW = "\x1b[33m"
@@ -23,8 +24,7 @@ export const initCommand: CliCommand = {
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(`${YELLOW}⚠${RESET} Config already exists: ${configPath} — remove it first if you want to reinitialize.`)
28
28
  return 1
29
29
  }
30
30
 
@@ -37,9 +37,9 @@ export const initCommand: CliCommand = {
37
37
  ? "Hardhat"
38
38
  : "unknown"
39
39
 
40
- console.log(`${GREEN}✓${RESET} Created ${configPath}`)
41
- console.log(` Project type: ${projectType}`)
42
- console.log(" Run 'argus doctor' to check dependencies.")
40
+ cliOutput.log(`${GREEN}✓${RESET} Created ${configPath}`)
41
+ cliOutput.log(` Project type: ${projectType}`)
42
+ cliOutput.log(" Run 'argus doctor' to check dependencies.")
43
43
 
44
44
  return 0
45
45
  },
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
  import { homedir } from "node:os"
4
4
  import type { CliCommand } from "../types"
5
+ import { cliOutput } from "../cli-output"
5
6
 
6
7
  const GREEN = "\x1b[32m"
7
8
  const YELLOW = "\x1b[33m"
@@ -26,8 +27,7 @@ export const installCommand: CliCommand = {
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(`${YELLOW}⚠${RESET} opencode.json not found — create one first, or run: opencode init`)
31
31
  return 1
32
32
  }
33
33
 
@@ -37,7 +37,7 @@ export const installCommand: CliCommand = {
37
37
  const plugins: string[] = config.plugin ?? []
38
38
 
39
39
  if (plugins.includes("solidity-argus")) {
40
- console.log(`${GREEN}✓${RESET} solidity-argus already registered in ${configPath}`)
40
+ cliOutput.log(`${GREEN}✓${RESET} solidity-argus already registered in ${configPath}`)
41
41
  return 0
42
42
  }
43
43
 
@@ -45,10 +45,10 @@ export const installCommand: CliCommand = {
45
45
  config.plugin = plugins
46
46
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
47
47
 
48
- console.log(`${GREEN}✓${RESET} Added solidity-argus to ${configPath}`)
48
+ cliOutput.log(`${GREEN}✓${RESET} Added solidity-argus to ${configPath}`)
49
49
  return 0
50
50
  } catch (error) {
51
- console.error(`${YELLOW}⚠${RESET} Failed to update ${configPath}`)
51
+ cliOutput.error(`${YELLOW}⚠${RESET} Failed to update ${configPath}`)
52
52
  return 1
53
53
  }
54
54
  },
@@ -0,0 +1,114 @@
1
+ import { readFileSync, readdirSync } from "node:fs"
2
+ import { extname, join } from "node:path"
3
+ import type { CliCommand } from "../types"
4
+ import { resolveSkillRoots } from "../../skills/argus-skill-resolver"
5
+ import { parseFrontmatter, validateSkillFrontmatter } from "../../skills/skill-schema"
6
+ import { loadArgusConfig } from "../../config/loader"
7
+ import { cliOutput } from "../cli-output"
8
+
9
+ const GREEN = "\x1b[32m"
10
+ const RED = "\x1b[31m"
11
+ const RESET = "\x1b[0m"
12
+
13
+ function findMarkdownFiles(dir: string, maxDepth = 8): string[] {
14
+ const files: string[] = []
15
+ const stack: Array<{ path: string; depth: number }> = [{ path: dir, depth: 0 }]
16
+
17
+ while (stack.length > 0) {
18
+ const current = stack.pop()
19
+ if (!current || current.depth > maxDepth) continue
20
+
21
+ try {
22
+ const entries = readdirSync(current.path, { withFileTypes: true })
23
+ for (const entry of entries) {
24
+ const fullPath = join(current.path, entry.name)
25
+ if (entry.isDirectory()) {
26
+ stack.push({ path: fullPath, depth: current.depth + 1 })
27
+ } else if (entry.isFile() && extname(entry.name).toLowerCase() === ".md") {
28
+ files.push(fullPath)
29
+ }
30
+ }
31
+ } catch {
32
+ continue
33
+ }
34
+ }
35
+
36
+ return files
37
+ }
38
+
39
+ export interface LintResult {
40
+ valid: number
41
+ invalid: number
42
+ skipped: number
43
+ errors: Array<{ file: string; errors: string[] }>
44
+ }
45
+
46
+ export function lintSkillFiles(skillFiles: Array<{ path: string; content: string }>): LintResult {
47
+ let valid = 0
48
+ let invalid = 0
49
+ let skipped = 0
50
+ const errors: Array<{ file: string; errors: string[] }> = []
51
+
52
+ for (const { path, content } of skillFiles) {
53
+ const fm = parseFrontmatter(content)
54
+ if (!fm) {
55
+ skipped++
56
+ continue
57
+ }
58
+
59
+ const result = validateSkillFrontmatter(fm)
60
+ if (result.success) {
61
+ valid++
62
+ } else {
63
+ invalid++
64
+ errors.push({ file: path, errors: result.errors })
65
+ }
66
+ }
67
+
68
+ return { valid, invalid, skipped, errors }
69
+ }
70
+
71
+ export const lintSkillsCommand: CliCommand = {
72
+ name: "lint-skills",
73
+ description: "Validate all SKILL.md files against schema",
74
+ async execute(): Promise<number> {
75
+ const cwd = process.cwd()
76
+ let config: ReturnType<typeof loadArgusConfig> | undefined
77
+ try {
78
+ config = loadArgusConfig(cwd)
79
+ } catch {
80
+ // fallback to undefined, resolveSkillRoots handles this
81
+ }
82
+
83
+ const roots = resolveSkillRoots(cwd, config)
84
+ const skillFiles: Array<{ path: string; content: string }> = []
85
+
86
+ for (const root of roots) {
87
+ const files = findMarkdownFiles(root.path)
88
+ for (const file of files) {
89
+ try {
90
+ skillFiles.push({ path: file, content: readFileSync(file, "utf8") })
91
+ } catch {
92
+ // continue on read errors
93
+ }
94
+ }
95
+ }
96
+
97
+ const result = lintSkillFiles(skillFiles)
98
+
99
+ cliOutput.log(`Skill Lint: ${result.valid} valid, ${result.invalid} invalid, ${result.skipped} skipped (no frontmatter)`)
100
+
101
+ if (result.errors.length > 0) {
102
+ for (const { file, errors } of result.errors) {
103
+ cliOutput.log(`\n${RED}✗${RESET} ${file}`)
104
+ for (const err of errors) {
105
+ cliOutput.log(` - ${err}`)
106
+ }
107
+ }
108
+ } else if (result.valid > 0) {
109
+ cliOutput.log(`${GREEN}✓${RESET} All skills pass schema validation`)
110
+ }
111
+
112
+ return result.invalid > 0 ? 1 : 0
113
+ },
114
+ }
@@ -1,3 +1,5 @@
1
+ import { cliOutput } from "./cli-output"
2
+
1
3
  const NON_INTERACTIVE =
2
4
  !process.stdin.isTTY || process.env.CI === "true" || process.env.ARGUS_NON_INTERACTIVE === "true"
3
5
 
@@ -29,10 +31,10 @@ export async function select(
29
31
  ): Promise<string> {
30
32
  if (NON_INTERACTIVE) return options[defaultIndex] ?? options[0] ?? ""
31
33
 
32
- console.log(message)
34
+ cliOutput.log(message)
33
35
  for (let i = 0; i < options.length; i++) {
34
36
  const marker = i === defaultIndex ? ">" : " "
35
- console.log(` ${marker} ${i + 1}. ${options[i]}`)
37
+ cliOutput.log(` ${marker} ${i + 1}. ${options[i]}`)
36
38
  }
37
39
 
38
40
  return new Promise((resolve) => {
@@ -23,6 +23,7 @@ const KnowledgeConfigSchema = z.object({
23
23
  }),
24
24
  autoSync: z.boolean().default(true),
25
25
  customSkillsDir: z.string().optional(),
26
+ skillPrecedence: z.enum(["bundled-first", "custom-first"]).default("bundled-first"),
26
27
  })
27
28
 
28
29
  const ReportingConfigSchema = z.object({
@@ -63,6 +64,7 @@ export const ArgusConfigSchema = z.object({
63
64
  apiUrl: "https://api.scvd.dev",
64
65
  },
65
66
  autoSync: true,
67
+ skillPrecedence: "bundled-first",
66
68
  }),
67
69
  reporting: ReportingConfigSchema.default({
68
70
  format: "markdown",