Skip to content

Commit db86869

Browse files
Peter MarreckPeter Marreck
authored andcommitted
feat: add 'codescan root' CLI subcommand and MCP tool
Reports the resolved project root from CWD (CLI) or from the MCP server's project_root (MCP). Bare absolute path on stdout for scripting; --json or MCP-tool form returns a richer object with project_root, codescan_dir, db_path, watcher_pid, watcher_status, walk_up_steps. Use case: dispatch / worktree co-location scripts that need to ask "which .codescan/ does codescan resolve to?" instead of duplicating the walk-up logic in bash. Also handy for human debugging when an edit lands in an unexpected project. Factors a new pub fn findRepoRootInfo() helper alongside the existing findRepoRoot() so both CLI and MCP share the same walk-up implementation. Also broadens .gitignore zig-cache/ and zig-out/ patterns to match anywhere in the tree (was root-only) so a stray src/zig-cache/ from a sub-directory build doesn't pollute git status. Closes inbox/2026-05-04-feature-request-codescan-root.md.
1 parent 78c6002 commit db86869

4 files changed

Lines changed: 149 additions & 3 deletions

File tree

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
/zig-cache/
2-
/zig-out/
1+
zig-cache/
2+
zig-out/
33
/.codescan/
44
/.codescan-fixtures/
55
/.serena/

src/cli.zig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub const CommandTag = enum {
3636
status,
3737
setup_model,
3838
log,
39+
root,
3940
};
4041
pub const ConfigAction = enum {
4142
show,
@@ -412,6 +413,10 @@ pub fn parse(allocator: std.mem.Allocator, args: []const []const u8) !Parsed {
412413
parsed.command = .log;
413414
help_topic_default = "log";
414415
i += 1;
416+
} else if (std.mem.eql(u8, cmd, "root")) {
417+
parsed.command = .root;
418+
help_topic_default = "root";
419+
i += 1;
415420
} else if (std.mem.eql(u8, cmd, "clean") or std.mem.eql(u8, cmd, "clear")) {
416421
parsed.command = .clean;
417422
help_topic_default = "clean";

src/main.zig

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,6 +1342,10 @@ pub fn main() !void {
13421342
try stdout.writeAll(log_output);
13431343
try stdout.flush();
13441344
},
1345+
.root => {
1346+
try runRoot(allocator, parsed.output, stdout);
1347+
try stdout.flush();
1348+
},
13451349
.setup_model => {
13461350
const dialect: setup_model_text.Dialect = switch (settings.embedding_dialect) {
13471351
.ollama => .ollama,
@@ -2111,6 +2115,41 @@ fn findRepoRoot(allocator: std.mem.Allocator, start_path: []const u8) !?[]u8 {
21112115
return findRepoRootUntil(allocator, start_path, null);
21122116
}
21132117

2118+
pub const RootInfo = struct {
2119+
project_root: []const u8, // absolute path of the dir containing .codescan/
2120+
codescan_dir: []const u8, // absolute path of the .codescan/ dir itself
2121+
walk_up_steps: usize, // number of dir levels traversed from start_path
2122+
};
2123+
2124+
/// Walk up from `start_path` looking for the nearest `.codescan/` ancestor.
2125+
/// Caller owns `project_root` and `codescan_dir` (each freed independently).
2126+
pub fn findRepoRootInfo(allocator: std.mem.Allocator, start_path: []const u8) !?RootInfo {
2127+
const start_abs = try std.fs.cwd().realpathAlloc(allocator, start_path);
2128+
errdefer allocator.free(start_abs);
2129+
2130+
var current = start_abs;
2131+
var steps: usize = 0;
2132+
while (true) {
2133+
if (try hasCodescanDir(current)) {
2134+
const codescan_dir = try std.fs.path.join(allocator, &.{ current, ".codescan" });
2135+
return RootInfo{
2136+
.project_root = current,
2137+
.codescan_dir = codescan_dir,
2138+
.walk_up_steps = steps,
2139+
};
2140+
}
2141+
const parent = std.fs.path.dirname(current) orelse break;
2142+
if (std.mem.eql(u8, parent, current)) break;
2143+
const next = try allocator.dupe(u8, parent);
2144+
allocator.free(current);
2145+
current = next;
2146+
steps += 1;
2147+
}
2148+
allocator.free(current);
2149+
return null;
2150+
}
2151+
2152+
21142153
fn findRepoRootUntil(
21152154
allocator: std.mem.Allocator,
21162155
start_path: []const u8,
@@ -5296,6 +5335,67 @@ fn appendJsonBoolFlag(
52965335
if (enabled) try args.append(allocator, flag);
52975336
}
52985337

5338+
pub fn runRoot(
5339+
allocator: std.mem.Allocator,
5340+
format: cli.OutputFormat,
5341+
writer: *std.Io.Writer,
5342+
) !void {
5343+
const cwd_path = try std.fs.cwd().realpathAlloc(allocator, ".");
5344+
defer allocator.free(cwd_path);
5345+
5346+
const info_opt = findRepoRootInfo(allocator, cwd_path) catch |err| {
5347+
if (format == .json) {
5348+
try writer.print("{{\"error\":\"{s}\"}}\n", .{@errorName(err)});
5349+
} else {
5350+
try writer.print("error: {s}\n", .{@errorName(err)});
5351+
}
5352+
return err;
5353+
};
5354+
5355+
const info = info_opt orelse {
5356+
if (format == .json) {
5357+
try writer.print("{{\"root\":null,\"error\":\"no .codescan/ directory found walking up from {s}\"}}\n", .{cwd_path});
5358+
} else {
5359+
try writer.print("error: no .codescan/ directory found walking up from {s}\n", .{cwd_path});
5360+
}
5361+
std.process.exit(1);
5362+
};
5363+
defer {
5364+
allocator.free(info.project_root);
5365+
allocator.free(info.codescan_dir);
5366+
}
5367+
5368+
if (format == .human) {
5369+
try writer.print("{s}\n", .{info.project_root});
5370+
return;
5371+
}
5372+
5373+
// JSON output: include codescan_dir, db_path, watcher pid/status, walk_up_steps
5374+
const db_path = try std.fs.path.join(allocator, &.{ info.codescan_dir, "index.sqlite3" });
5375+
defer allocator.free(db_path);
5376+
5377+
const pid_opt = pidfile.readAndCheckPid(allocator, info.codescan_dir) catch null;
5378+
const watcher_status: []const u8 = if (pidfile.isWatcherRunning(allocator, info.codescan_dir))
5379+
"running"
5380+
else if (pid_opt != null)
5381+
"stale"
5382+
else
5383+
"stopped";
5384+
5385+
try writer.writeAll("{");
5386+
try writer.print("\"project_root\":\"{s}\",", .{info.project_root});
5387+
try writer.print("\"root\":\"{s}\",", .{info.codescan_dir});
5388+
try writer.print("\"db_path\":\"{s}\",", .{db_path});
5389+
if (pid_opt) |pid| {
5390+
try writer.print("\"watcher_pid\":{d},", .{pid});
5391+
} else {
5392+
try writer.writeAll("\"watcher_pid\":null,");
5393+
}
5394+
try writer.print("\"watcher_status\":\"{s}\",", .{watcher_status});
5395+
try writer.print("\"walk_up_steps\":{d}", .{info.walk_up_steps});
5396+
try writer.writeAll("}\n");
5397+
}
5398+
52995399
pub fn runStatus(
53005400
allocator: std.mem.Allocator,
53015401
db_path: []const u8,

src/mcp.zig

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,46 @@ fn callTool(allocator: std.mem.Allocator, name: []const u8, args: ?std.json.Obje
656656
defer allocator.free(log_output);
657657

658658
try out.writer.writeAll(log_output);
659+
} else if (std.mem.eql(u8, name, "root")) {
660+
// Resolve from the server's project root rather than CWD — the MCP
661+
// server may have been launched from anywhere; settings.root_path
662+
// is the authoritative starting point.
663+
const info_opt = main.findRepoRootInfo(allocator, settings.root_path) catch |err|
664+
return toolError("MCP root: walk-up failed: {}\n", .{err});
665+
666+
if (info_opt) |info| {
667+
defer {
668+
allocator.free(info.project_root);
669+
allocator.free(info.codescan_dir);
670+
}
671+
const db_path = std.fs.path.join(allocator, &.{ info.codescan_dir, "index.sqlite3" }) catch
672+
return toolError("MCP root: db_path join failed\n", .{});
673+
defer allocator.free(db_path);
674+
675+
const pidfile_mod = @import("pidfile.zig");
676+
const pid_opt = pidfile_mod.readAndCheckPid(allocator, info.codescan_dir) catch null;
677+
const watcher_status: []const u8 = if (pidfile_mod.isWatcherRunning(allocator, info.codescan_dir))
678+
"running"
679+
else if (pid_opt != null)
680+
"stale"
681+
else
682+
"stopped";
683+
684+
try out.writer.writeAll("{");
685+
try out.writer.print("\"project_root\":\"{s}\",", .{info.project_root});
686+
try out.writer.print("\"root\":\"{s}\",", .{info.codescan_dir});
687+
try out.writer.print("\"db_path\":\"{s}\",", .{db_path});
688+
if (pid_opt) |pid| {
689+
try out.writer.print("\"watcher_pid\":{d},", .{pid});
690+
} else {
691+
try out.writer.writeAll("\"watcher_pid\":null,");
692+
}
693+
try out.writer.print("\"watcher_status\":\"{s}\",", .{watcher_status});
694+
try out.writer.print("\"walk_up_steps\":{d}", .{info.walk_up_steps});
695+
try out.writer.writeAll("}");
696+
} else {
697+
try out.writer.print("{{\"root\":null,\"error\":\"no .codescan/ directory found walking up from {s}\"}}", .{settings.root_path});
698+
}
659699
} else {
660700
return error.UnknownTool;
661701
}
@@ -855,7 +895,8 @@ const tools_list_json =
855895
\\{"name":"rename","description":"Rename a symbol across the workspace (via LSP)","inputSchema":{"type":"object","properties":{"file":{"type":"string","description":"File path"},"pattern":{"type":"string","description":"Symbol name path"},"to":{"type":"string","description":"New name"},"dry_run":{"type":"boolean","description":"Preview changes without applying"}},"required":["file","pattern","to"]}},
856896
\\{"name":"config","description":"Show current codescan configuration","inputSchema":{"type":"object","properties":{}}},
857897
\\{"name":"status","description":"Show index and watcher status","inputSchema":{"type":"object","properties":{}}},
858-
\\{"name":"logs","description":"Read recent watcher logs from the OS log (macOS unified log / Linux journald), filtered to codescan and optionally to a project root.","inputSchema":{"type":"object","properties":{"root":{"type":"string","description":"Absolute project root path; filters messages to this project"},"since":{"type":"string","description":"Time window (e.g. '1h', '15m'). Default 1h."},"limit":{"type":"integer","description":"Max lines to return (last N after filter)"},"all":{"type":"boolean","description":"If true, show logs from all codescan projects"}}}}
898+
\\{"name":"logs","description":"Read recent watcher logs from the OS log (macOS unified log / Linux journald), filtered to codescan and optionally to a project root.","inputSchema":{"type":"object","properties":{"root":{"type":"string","description":"Absolute project root path; filters messages to this project"},"since":{"type":"string","description":"Time window (e.g. '1h', '15m'). Default 1h."},"limit":{"type":"integer","description":"Max lines to return (last N after filter)"},"all":{"type":"boolean","description":"If true, show logs from all codescan projects"}}}},
899+
\\{"name":"root","description":"Report which .codescan/ directory codescan resolves to from the server's project root. Returns absolute path of project root, the .codescan dir, db_path, watcher pid/status, and how many directory levels were walked up.","inputSchema":{"type":"object","properties":{}}}
859900
\\]}
860901
;
861902

0 commit comments

Comments
 (0)