Skip to content

Commit a720d7b

Browse files
aksOpsclaude
andcommitted
feat: add Phase 4 — Picocli CLI layer with all 11 commands
Integrate Picocli Spring Boot starter for CLI command routing with full Spring dependency injection. Profile-aware startup: non-serve commands run in indexing mode (no web server), serve command activates serving profile with full web server. Commands: analyze, serve, graph, query, find, cypher, flow, bundle, cache (stats/clear), plugins (list/info), version. - Add picocli + picocli-spring-boot-starter 4.7.7 dependencies - Modify CodeIqApplication to implement CommandLineRunner + ExitCodeGenerator - Create CodeIqCli top-level command with 11 subcommands - Rich ANSI-colored output via CliOutput utility - 62 new tests (758 total, all passing, 85%+ coverage maintained) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 07944fe commit a720d7b

28 files changed

Lines changed: 2254 additions & 4 deletions

pom.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<neo4j.version>2026.02.3</neo4j.version>
2626
<hazelcast.version>5.6.0</hazelcast.version>
2727
<spring-ai.version>1.1.4</spring-ai.version>
28+
<picocli.version>4.7.7</picocli.version>
2829
<jacoco.version>0.8.14</jacoco.version>
2930
<spotbugs.version>4.9.8.3</spotbugs.version>
3031
<owasp.dependency-check.version>12.2.0</owasp.dependency-check.version>
@@ -82,6 +83,18 @@
8283
<version>${hazelcast.version}</version>
8384
</dependency>
8485

86+
<!-- Picocli CLI framework -->
87+
<dependency>
88+
<groupId>info.picocli</groupId>
89+
<artifactId>picocli-spring-boot-starter</artifactId>
90+
<version>${picocli.version}</version>
91+
</dependency>
92+
<dependency>
93+
<groupId>info.picocli</groupId>
94+
<artifactId>picocli</artifactId>
95+
<version>${picocli.version}</version>
96+
</dependency>
97+
8598
<!-- JavaParser for AST-based Java detection -->
8699
<dependency>
87100
<groupId>com.github.javaparser</groupId>
Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,64 @@
11
package io.github.randomcodespace.iq;
22

3+
import io.github.randomcodespace.iq.cli.CodeIqCli;
4+
import org.springframework.boot.CommandLineRunner;
5+
import org.springframework.boot.ExitCodeGenerator;
36
import org.springframework.boot.SpringApplication;
47
import org.springframework.boot.autoconfigure.SpringBootApplication;
58
import org.springframework.cache.annotation.EnableCaching;
9+
import picocli.CommandLine;
10+
import picocli.CommandLine.IFactory;
611

12+
import java.util.Arrays;
13+
14+
/**
15+
* Main application entry point for OSSCodeIQ.
16+
* <p>
17+
* Uses Picocli with Spring Boot integration for CLI command routing.
18+
* Profile selection:
19+
* <ul>
20+
* <li>{@code serve} command → "serving" profile (web server enabled)</li>
21+
* <li>All other commands → "indexing" profile (no web server)</li>
22+
* </ul>
23+
*/
724
@SpringBootApplication
825
@EnableCaching
9-
public class CodeIqApplication {
26+
public class CodeIqApplication implements CommandLineRunner, ExitCodeGenerator {
27+
28+
private final CodeIqCli codeIqCli;
29+
private final IFactory factory;
30+
private int exitCode;
31+
32+
public CodeIqApplication(CodeIqCli codeIqCli, IFactory factory) {
33+
this.codeIqCli = codeIqCli;
34+
this.factory = factory;
35+
}
36+
37+
@Override
38+
public void run(String... args) {
39+
exitCode = new CommandLine(codeIqCli, factory).execute(args);
40+
}
41+
42+
@Override
43+
public int getExitCode() {
44+
return exitCode;
45+
}
1046

1147
public static void main(String[] args) {
12-
SpringApplication.run(CodeIqApplication.class, args);
48+
var app = new SpringApplication(CodeIqApplication.class);
49+
50+
// Detect if "serve" is among the arguments
51+
boolean isServe = Arrays.stream(args)
52+
.anyMatch(arg -> "serve".equalsIgnoreCase(arg));
53+
54+
if (isServe) {
55+
app.setAdditionalProfiles("serving");
56+
} else {
57+
app.setAdditionalProfiles("indexing");
58+
// Disable web server for non-serve commands
59+
app.setWebApplicationType(org.springframework.boot.WebApplicationType.NONE);
60+
}
61+
62+
System.exit(SpringApplication.exit(app.run(args)));
1363
}
1464
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package io.github.randomcodespace.iq.cli;
2+
3+
import io.github.randomcodespace.iq.analyzer.AnalysisResult;
4+
import io.github.randomcodespace.iq.analyzer.Analyzer;
5+
import io.github.randomcodespace.iq.config.CodeIqConfig;
6+
import org.springframework.stereotype.Component;
7+
import picocli.CommandLine.Command;
8+
import picocli.CommandLine.Option;
9+
import picocli.CommandLine.Parameters;
10+
11+
import java.nio.file.Path;
12+
import java.util.Map;
13+
import java.util.concurrent.Callable;
14+
15+
/**
16+
* Scan a codebase and build a knowledge graph.
17+
*/
18+
@Component
19+
@Command(name = "analyze", mixinStandardHelpOptions = true,
20+
description = "Scan codebase and build knowledge graph")
21+
public class AnalyzeCommand implements Callable<Integer> {
22+
23+
@Parameters(index = "0", defaultValue = ".", description = "Path to codebase root")
24+
private Path path;
25+
26+
@Option(names = {"--no-cache"}, description = "Skip incremental cache")
27+
private boolean noCache;
28+
29+
private final Analyzer analyzer;
30+
private final CodeIqConfig config;
31+
32+
public AnalyzeCommand(Analyzer analyzer, CodeIqConfig config) {
33+
this.analyzer = analyzer;
34+
this.config = config;
35+
}
36+
37+
@Override
38+
public Integer call() {
39+
Path root = path.toAbsolutePath().normalize();
40+
CliOutput.step("\uD83D\uDD0D", "Scanning " + root + " ...");
41+
42+
AnalysisResult result = analyzer.run(root, msg -> {
43+
if (msg.startsWith("Discovering")) {
44+
CliOutput.step("\uD83D\uDD0D", msg);
45+
} else if (msg.startsWith("Found")) {
46+
CliOutput.step("\uD83D\uDCC1", "@|cyan " + msg + "|@");
47+
} else if (msg.startsWith("Analyzing")) {
48+
CliOutput.step("\u2699\uFE0F", msg);
49+
} else if (msg.startsWith("Linking")) {
50+
CliOutput.step("\uD83D\uDD17", msg);
51+
} else if (msg.startsWith("Building")) {
52+
CliOutput.step("\uD83C\uDFD7\uFE0F", msg);
53+
} else if (msg.startsWith("Classifying")) {
54+
CliOutput.step("\uD83C\uDFF7\uFE0F", msg);
55+
} else if (msg.startsWith("Analysis complete")) {
56+
// handled below
57+
} else {
58+
CliOutput.info(msg);
59+
}
60+
});
61+
62+
System.out.println();
63+
CliOutput.success("\u2705 Analysis complete");
64+
System.out.println();
65+
CliOutput.info(" Files discovered: " + result.totalFiles());
66+
CliOutput.info(" Files analyzed: " + result.filesAnalyzed());
67+
CliOutput.cyan(" Nodes: " + result.nodeCount());
68+
CliOutput.cyan(" Edges: " + result.edgeCount());
69+
CliOutput.info(" Duration: " + result.elapsed().toMillis() + " ms");
70+
71+
if (!result.languageBreakdown().isEmpty()) {
72+
System.out.println();
73+
CliOutput.bold(" Languages:");
74+
result.languageBreakdown().entrySet().stream()
75+
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
76+
.limit(10)
77+
.forEach(e -> CliOutput.info(" " + e.getKey() + ": " + e.getValue()));
78+
}
79+
80+
if (!result.nodeBreakdown().isEmpty()) {
81+
System.out.println();
82+
CliOutput.bold(" Node kinds:");
83+
result.nodeBreakdown().entrySet().stream()
84+
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
85+
.limit(10)
86+
.forEach(e -> CliOutput.info(" " + e.getKey() + ": " + e.getValue()));
87+
}
88+
89+
return 0;
90+
}
91+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package io.github.randomcodespace.iq.cli;
2+
3+
import io.github.randomcodespace.iq.config.CodeIqConfig;
4+
import org.springframework.stereotype.Component;
5+
import picocli.CommandLine.Command;
6+
import picocli.CommandLine.Option;
7+
import picocli.CommandLine.Parameters;
8+
9+
import java.io.IOException;
10+
import java.nio.charset.StandardCharsets;
11+
import java.nio.file.Files;
12+
import java.nio.file.Path;
13+
import java.time.Instant;
14+
import java.util.concurrent.Callable;
15+
import java.util.zip.ZipEntry;
16+
import java.util.zip.ZipOutputStream;
17+
18+
/**
19+
* Package graph + source into a distributable ZIP bundle.
20+
*/
21+
@Component
22+
@Command(name = "bundle", mixinStandardHelpOptions = true,
23+
description = "Package graph + source into distributable ZIP")
24+
public class BundleCommand implements Callable<Integer> {
25+
26+
@Parameters(index = "0", defaultValue = ".", description = "Path to analyzed codebase")
27+
private Path path;
28+
29+
@Option(names = {"--tag", "-t"}, description = "Bundle tag/version")
30+
private String tag;
31+
32+
@Option(names = {"--output", "-o"}, description = "Output ZIP path (default: code-iq-bundle.zip)")
33+
private Path output;
34+
35+
private final CodeIqConfig config;
36+
37+
public BundleCommand(CodeIqConfig config) {
38+
this.config = config;
39+
}
40+
41+
@Override
42+
public Integer call() {
43+
Path root = path.toAbsolutePath().normalize();
44+
Path graphDir = root.resolve(config.getCacheDir());
45+
46+
if (!Files.isDirectory(graphDir)) {
47+
CliOutput.error("No analysis data found at " + graphDir);
48+
CliOutput.info("Run 'code-iq analyze " + root + "' first.");
49+
return 1;
50+
}
51+
52+
Path zipPath = output != null ? output
53+
: root.resolve("code-iq-bundle.zip");
54+
55+
CliOutput.step("\uD83D\uDCE6", "Creating bundle...");
56+
57+
try (var zos = new ZipOutputStream(Files.newOutputStream(zipPath))) {
58+
// Write manifest
59+
String manifest = createManifest(root);
60+
zos.putNextEntry(new ZipEntry("manifest.json"));
61+
zos.write(manifest.getBytes(StandardCharsets.UTF_8));
62+
zos.closeEntry();
63+
64+
// Bundle graph data directory
65+
try (var walk = Files.walk(graphDir)) {
66+
walk.filter(Files::isRegularFile).forEach(file -> {
67+
try {
68+
String entryName = "graph/" + graphDir.relativize(file);
69+
zos.putNextEntry(new ZipEntry(entryName));
70+
Files.copy(file, zos);
71+
zos.closeEntry();
72+
} catch (IOException e) {
73+
CliOutput.warn("Skipped file: " + file + " (" + e.getMessage() + ")");
74+
}
75+
});
76+
}
77+
78+
CliOutput.success("\u2705 Bundle created: " + zipPath);
79+
CliOutput.info(" Tag: " + (tag != null ? tag : "untagged"));
80+
CliOutput.info(" Size: " + Files.size(zipPath) / 1024 + " KB");
81+
} catch (IOException e) {
82+
CliOutput.error("Failed to create bundle: " + e.getMessage());
83+
return 1;
84+
}
85+
86+
return 0;
87+
}
88+
89+
private String createManifest(Path root) {
90+
return """
91+
{
92+
"tool": "code-iq",
93+
"version": "0.1.0-SNAPSHOT",
94+
"tag": "%s",
95+
"created_at": "%s",
96+
"root": "%s"
97+
}
98+
""".formatted(
99+
tag != null ? tag : "",
100+
Instant.now().toString(),
101+
root.getFileName()
102+
);
103+
}
104+
}

0 commit comments

Comments
 (0)