nitpiq 0.1.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.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # nitpiq
2
+
3
+ Terminal-based code review tool for local git changes. Built with Bun, TypeScript, React (Ink), and the React Compiler.
4
+
5
+ Inspect uncommitted changes, leave anchored review comments, and expose an MCP server so AI tools can participate as a second reviewer.
6
+
7
+ Review data is stored locally in `.git/nitpiq/review.db`.
8
+
9
+ ## Prerequisites
10
+
11
+ - [Bun](https://bun.sh) v1.1+
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ bun install -g nitpiq
17
+ ```
18
+
19
+ Or from source:
20
+
21
+ ```bash
22
+ git clone <repo-url> && cd nitpiq
23
+ bun install
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### TUI
29
+
30
+ Run inside any git repository:
31
+
32
+ ```bash
33
+ nitpiq
34
+ ```
35
+
36
+ Or with npx:
37
+
38
+ ```bash
39
+ npx nitpiq
40
+ ```
41
+
42
+ Options:
43
+
44
+ ```
45
+ --theme=<name> Set color theme (dark, catppuccin, nord, gruvbox)
46
+ --demo Launch with fixed demo data (no git required)
47
+ --snapshot Render a single frame and exit (for screenshots)
48
+ ```
49
+
50
+ ### MCP Server
51
+
52
+ Start the MCP server for AI tool integration:
53
+
54
+ ```bash
55
+ nitpiq-mcp /path/to/repo
56
+ ```
57
+
58
+ Or with npx (useful for MCP client configuration):
59
+
60
+ ```bash
61
+ npx nitpiq-mcp /path/to/repo
62
+ ```
63
+
64
+ The server exposes these tools over stdio:
65
+
66
+ | Tool | Description |
67
+ |------|-------------|
68
+ | `review_list_changes` | List uncommitted file changes |
69
+ | `review_list_threads` | List review threads (filterable by file/status) |
70
+ | `review_reply_thread` | Add a reply to a thread |
71
+ | `review_resolve_thread` | Mark a thread as resolved |
72
+ | `review_reopen_thread` | Reopen a resolved thread |
73
+ | `review_apply_edit` | Write content to a file |
74
+ | `review_stage_file` | Stage a file |
75
+ | `review_unstage_file` | Unstage a file |
76
+
77
+ #### MCP Client Configuration
78
+
79
+ For Claude Desktop, add to your config:
80
+
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "nitpiq": {
85
+ "command": "npx",
86
+ "args": ["nitpiq-mcp", "/path/to/your/repo"]
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ For Cursor, add to `.cursor/mcp.json`:
93
+
94
+ ```json
95
+ {
96
+ "mcpServers": {
97
+ "nitpiq": {
98
+ "command": "npx",
99
+ "args": ["nitpiq-mcp", "/path/to/your/repo"]
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ ## Keybindings
106
+
107
+ ### File Sidebar
108
+
109
+ | Key | Action |
110
+ |-----|--------|
111
+ | `j` / `k` | Navigate files |
112
+ | `l` / `Enter` | Open file in diff pane |
113
+ | `/` | Filter files by name |
114
+ | `s` | Stage / unstage file |
115
+ | `f` | Toggle between git changes and all files |
116
+ | `r` | Refresh |
117
+ | `Tab` | Switch to diff pane |
118
+ | `q` | Quit |
119
+
120
+ ### Diff Pane
121
+
122
+ **Navigation (vim-style, supports count prefix e.g. `5j`):**
123
+
124
+ | Key | Action |
125
+ |-----|--------|
126
+ | `j` / `k` | Line up / down |
127
+ | `gg` | Jump to top (or `[count]gg` to line N) |
128
+ | `G` | Jump to bottom (or `[count]G` to line N) |
129
+ | `Ctrl+D` / `Ctrl+U` | Half-page down / up |
130
+ | `Ctrl+F` / `Ctrl+B` | Full-page down / up |
131
+ | `H` / `M` / `L` | Top / middle / bottom of visible screen |
132
+ | `{` / `}` | Previous / next block (hunk headers, blank lines) |
133
+ | `w` / `b` | Next / previous changed line |
134
+ | `[` / `]` | Previous / next review thread (cross-file) |
135
+ | `zz` / `zt` / `zb` | Center / top / bottom current line on screen |
136
+ | `:` | Go to line number |
137
+
138
+ **Actions:**
139
+
140
+ | Key | Action |
141
+ |-----|--------|
142
+ | `c` | Comment on current line (or reply if thread exists) |
143
+ | `v` | Enter visual mode for range selection |
144
+ | `d` | Delete thread at cursor (with confirmation) |
145
+ | `r` | Resolve / reopen thread at cursor |
146
+ | `/` | Search diff |
147
+ | `n` / `N` | Next / previous search match |
148
+ | `f` | Toggle diff view / full file view |
149
+ | `e` | Cycle diff context (3 / 10 / full) |
150
+ | `h` / `Esc` / `q` | Back to file sidebar |
151
+
152
+ ### Visual Mode
153
+
154
+ | Key | Action |
155
+ |-----|--------|
156
+ | `j` / `k` | Extend selection |
157
+ | `c` | Comment on selected range |
158
+ | `Esc` | Cancel |
159
+
160
+ ## Themes
161
+
162
+ Set with `--theme=<name>`:
163
+
164
+ - **dark** (default) -- blue accent on dark background
165
+ - **catppuccin** -- pastel mocha palette
166
+ - **nord** -- arctic blue tones
167
+ - **gruvbox** -- warm retro colors
168
+
169
+ ## Type Check
170
+
171
+ ```bash
172
+ bun run check
173
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/cli/nitpiq-mcp.ts";
package/bin/nitpiq.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/cli/nitpiq.tsx";
package/bunfig.toml ADDED
@@ -0,0 +1 @@
1
+ preload = ["./plugins/react-compiler.ts"]
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "nitpiq",
3
+ "version": "0.1.0",
4
+ "description": "Terminal-based code review tool for local git changes",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "nitpiq": "bin/nitpiq.ts",
9
+ "nitpiq-mcp": "bin/nitpiq-mcp.ts"
10
+ },
11
+ "files": [
12
+ "bin/",
13
+ "src/",
14
+ "plugins/",
15
+ "bunfig.toml"
16
+ ],
17
+ "scripts": {
18
+ "nitpiq": "bun run src/cli/nitpiq.tsx",
19
+ "nitpiq-mcp": "bun run src/cli/nitpiq-mcp.ts",
20
+ "dev": "bun run src/cli/nitpiq.tsx",
21
+ "check": "tsc --noEmit"
22
+ },
23
+ "devDependencies": {
24
+ "@babel/core": "^7.29.0",
25
+ "@babel/plugin-syntax-jsx": "^7.28.6",
26
+ "@babel/plugin-syntax-typescript": "^7.28.6",
27
+ "@types/bun": "latest",
28
+ "@types/react": "^19.2.14",
29
+ "babel-plugin-react-compiler": "^1.0.0",
30
+ "eslint-plugin-react-hooks": "^7.0.1"
31
+ },
32
+ "peerDependencies": {
33
+ "typescript": "^5"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.27.1",
37
+ "fuse.js": "^7.1.0",
38
+ "ink": "^6.8.0",
39
+ "parse-diff": "^0.11.1",
40
+ "picocolors": "^1.1.1",
41
+ "react": "^19.2.4",
42
+ "zod": "^4.3.6"
43
+ }
44
+ }
@@ -0,0 +1,28 @@
1
+ import { plugin } from "bun";
2
+ import { transformSync } from "@babel/core";
3
+ import ReactCompilerPlugin from "babel-plugin-react-compiler";
4
+
5
+ plugin({
6
+ name: "react-compiler",
7
+ setup(build) {
8
+ build.onLoad({ filter: /\.tsx$/ }, async (args) => {
9
+ const source = await Bun.file(args.path).text();
10
+
11
+ const result = transformSync(source, {
12
+ filename: args.path,
13
+ plugins: [
14
+ [ReactCompilerPlugin, {}],
15
+ ["@babel/plugin-syntax-typescript", { isTSX: true }],
16
+ "@babel/plugin-syntax-jsx",
17
+ ],
18
+ configFile: false,
19
+ babelrc: false,
20
+ });
21
+
22
+ return {
23
+ contents: result?.code ?? source,
24
+ loader: "tsx",
25
+ };
26
+ });
27
+ },
28
+ });
@@ -0,0 +1,10 @@
1
+ import { serveStdio } from "../mcp/server";
2
+
3
+ const repoPath = process.argv[2];
4
+
5
+ try {
6
+ await serveStdio(repoPath);
7
+ } catch (error) {
8
+ console.error(`nitpiq-mcp: ${error instanceof Error ? error.message : String(error)}`);
9
+ process.exit(1);
10
+ }
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { render } from "ink";
3
+ import { openRepoAt } from "../git/repo";
4
+ import { initLog } from "../log/log";
5
+ import { Store } from "../store/store";
6
+ import { NitpiqApp } from "../tui/app";
7
+ import { createDemoState } from "../tui/demo";
8
+
9
+ try {
10
+ const args = process.argv.slice(2);
11
+ const demo = args.includes("--demo");
12
+ const snapshot = args.includes("--snapshot");
13
+ const themeArg = args.find((a) => a.startsWith("--theme="));
14
+ const themeName = themeArg?.split("=")[1];
15
+ const demoState = demo ? createDemoState() : undefined;
16
+ const repo = demoState?.repo ?? openRepoAt(process.cwd());
17
+ const store = demo ? null : Store.open(repo.root);
18
+ if (!demo) {
19
+ initLog(repo.root);
20
+ }
21
+ const useAltScreen = Boolean(process.stdout.isTTY);
22
+ if (useAltScreen) {
23
+ process.stdout.write("\u001b[?1049h\u001b[H");
24
+ }
25
+
26
+ const instance = render(<NitpiqApp repo={repo} store={store} demoState={demoState} snapshot={snapshot} theme={themeName} />);
27
+ instance.waitUntilExit().finally(() => {
28
+ if (useAltScreen) {
29
+ process.stdout.write("\u001b[?1049l");
30
+ }
31
+ store?.close();
32
+ });
33
+ } catch (error) {
34
+ console.error(`nitpiq: ${error instanceof Error ? error.message : String(error)}`);
35
+ process.exit(1);
36
+ }
@@ -0,0 +1,237 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export type ChangeKind = "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked";
5
+
6
+ export interface FileChange {
7
+ path: string;
8
+ oldPath: string;
9
+ kind: ChangeKind;
10
+ staged: boolean;
11
+ unstaged: boolean;
12
+ }
13
+
14
+ export interface Repo {
15
+ root: string;
16
+ name: string;
17
+ hasHead: boolean;
18
+ }
19
+
20
+ export function kindSymbol(kind: ChangeKind): string {
21
+ switch (kind) {
22
+ case "modified":
23
+ return "M";
24
+ case "added":
25
+ return "A";
26
+ case "deleted":
27
+ return "D";
28
+ case "renamed":
29
+ return "R";
30
+ case "copied":
31
+ return "C";
32
+ case "untracked":
33
+ return "?";
34
+ }
35
+ }
36
+
37
+ export function openRepoAt(dir?: string): Repo {
38
+ const root = runGit(["rev-parse", "--show-toplevel"], dir || process.cwd()).trim();
39
+ const hasHead = runGitOptional(["rev-parse", "HEAD"], root).ok;
40
+
41
+ return {
42
+ root,
43
+ name: path.basename(root),
44
+ hasHead,
45
+ };
46
+ }
47
+
48
+ export function openRepo(): Repo {
49
+ return openRepoAt();
50
+ }
51
+
52
+ export function changes(repo: Repo): FileChange[] {
53
+ const output = runGit(["status", "--porcelain=v1", "-uall"], repo.root);
54
+ return parsePorcelain(output);
55
+ }
56
+
57
+ export function files(repo: Repo): string[] {
58
+ const output = runGit(["ls-files", "--cached", "--others", "--exclude-standard"], repo.root);
59
+ const seen = new Set<string>();
60
+ const result: string[] = [];
61
+
62
+ for (const line of output.split("\n")) {
63
+ const candidate = line.trim();
64
+ if (!candidate || seen.has(candidate)) {
65
+ continue;
66
+ }
67
+ seen.add(candidate);
68
+ result.push(candidate);
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ export function diff(repo: Repo, change: FileChange, contextLines = 3): string {
75
+ if (change.kind === "untracked") {
76
+ return diffUntracked(repo, change.path);
77
+ }
78
+
79
+ const context = `-U${contextLines}`;
80
+
81
+ if (repo.hasHead) {
82
+ const result = runGitOptional(["diff", context, "HEAD", "--", change.path], repo.root);
83
+ if (result.ok && result.stdout.trim()) {
84
+ return result.stdout;
85
+ }
86
+ }
87
+
88
+ const cached = runGitOptional(["diff", context, "--cached", "--", change.path], repo.root);
89
+ if (cached.ok && cached.stdout.trim()) {
90
+ return cached.stdout;
91
+ }
92
+
93
+ const working = runGitOptional(["diff", context, "--", change.path], repo.root);
94
+ if (working.ok && working.stdout.trim()) {
95
+ return working.stdout;
96
+ }
97
+
98
+ return "(no diff available)";
99
+ }
100
+
101
+ function diffUntracked(repo: Repo, relativePath: string): string {
102
+ const absolutePath = path.join(repo.root, relativePath);
103
+ const stat = statSync(absolutePath);
104
+ if (stat.isDirectory()) {
105
+ return `(directory: ${relativePath})`;
106
+ }
107
+
108
+ const result = Bun.spawnSync(["git", "diff", "--no-index", "--", "/dev/null", absolutePath], {
109
+ cwd: repo.root,
110
+ stdout: "pipe",
111
+ stderr: "pipe",
112
+ });
113
+
114
+ const text = `${result.stdout ? Buffer.from(result.stdout).toString() : ""}${result.stderr ? Buffer.from(result.stderr).toString() : ""}`;
115
+ return text || "(empty file)";
116
+ }
117
+
118
+ export function readFile(repo: Repo, relativePath: string): string {
119
+ const filePath = path.join(repo.root, relativePath);
120
+ const stat = statSync(filePath);
121
+ if (stat.isDirectory()) {
122
+ throw new Error(`${relativePath} is a directory`);
123
+ }
124
+ return readFileSync(filePath, "utf8");
125
+ }
126
+
127
+ export function stage(repo: Repo, relativePath: string): void {
128
+ runGit(["add", "--", relativePath], repo.root);
129
+ }
130
+
131
+ export function unstage(repo: Repo, relativePath: string): void {
132
+ const restore = runGitOptional(["restore", "--staged", "--", relativePath], repo.root);
133
+ if (restore.ok) {
134
+ return;
135
+ }
136
+
137
+ const reset = runGitOptional(["reset", "HEAD", "--", relativePath], repo.root);
138
+ if (reset.ok) {
139
+ return;
140
+ }
141
+
142
+ const remove = runGitOptional(["rm", "--cached", "--", relativePath], repo.root);
143
+ if (!remove.ok) {
144
+ throw new Error(remove.stderr || `failed to unstage ${relativePath}`);
145
+ }
146
+ }
147
+
148
+ function parsePorcelain(output: string): FileChange[] {
149
+ const result: FileChange[] = [];
150
+
151
+ for (const line of output.split("\n")) {
152
+ if (line.length < 4) {
153
+ continue;
154
+ }
155
+
156
+ const x = line[0] ?? " ";
157
+ const y = line[1] ?? " ";
158
+ let filePath = line.slice(3);
159
+ let oldPath = "";
160
+
161
+ if (filePath.endsWith("/")) {
162
+ continue;
163
+ }
164
+
165
+ const renameIndex = filePath.indexOf(" -> ");
166
+ if (renameIndex >= 0) {
167
+ oldPath = filePath.slice(0, renameIndex);
168
+ filePath = filePath.slice(renameIndex + 4);
169
+ }
170
+
171
+ const change: FileChange = {
172
+ path: filePath,
173
+ oldPath,
174
+ kind: "modified",
175
+ staged: false,
176
+ unstaged: false,
177
+ };
178
+
179
+ if (x === "?" && y === "?") {
180
+ change.kind = "untracked";
181
+ change.unstaged = true;
182
+ } else {
183
+ if (x !== " " && x !== "?") {
184
+ change.staged = true;
185
+ change.kind = charToKind(x);
186
+ }
187
+ if (y !== " " && y !== "?") {
188
+ change.unstaged = true;
189
+ if (!change.staged) {
190
+ change.kind = charToKind(y);
191
+ }
192
+ }
193
+ }
194
+
195
+ result.push(change);
196
+ }
197
+
198
+ return result;
199
+ }
200
+
201
+ function charToKind(char: string): ChangeKind {
202
+ switch (char) {
203
+ case "A":
204
+ return "added";
205
+ case "D":
206
+ return "deleted";
207
+ case "R":
208
+ return "renamed";
209
+ case "C":
210
+ return "copied";
211
+ case "M":
212
+ default:
213
+ return "modified";
214
+ }
215
+ }
216
+
217
+ function runGit(args: string[], cwd: string): string {
218
+ const result = runGitOptional(args, cwd);
219
+ if (!result.ok) {
220
+ throw new Error(result.stderr || `git ${args.join(" ")} failed`);
221
+ }
222
+ return result.stdout;
223
+ }
224
+
225
+ function runGitOptional(args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } {
226
+ const proc = Bun.spawnSync(["git", ...args], {
227
+ cwd,
228
+ stdout: "pipe",
229
+ stderr: "pipe",
230
+ });
231
+
232
+ return {
233
+ ok: proc.exitCode === 0,
234
+ stdout: proc.stdout ? Buffer.from(proc.stdout).toString() : "",
235
+ stderr: proc.stderr ? Buffer.from(proc.stderr).toString().trim() : "",
236
+ };
237
+ }
package/src/log/log.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { mkdirSync, appendFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ let logPath: string | null = null;
5
+
6
+ export function initLog(repoRoot: string): void {
7
+ const dir = path.join(repoRoot, ".git", "nitpiq");
8
+ mkdirSync(dir, { recursive: true });
9
+ logPath = path.join(dir, "debug.log");
10
+ }
11
+
12
+ function write(level: string, message: string): void {
13
+ if (!logPath) {
14
+ return;
15
+ }
16
+
17
+ const line = `[${new Date().toISOString()}] ${level} ${message}\n`;
18
+ appendFileSync(logPath, line);
19
+ }
20
+
21
+ export function debug(message: string): void {
22
+ write("DEBUG", message);
23
+ }
24
+
25
+ export function error(message: string): void {
26
+ write("ERROR", message);
27
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdirSync, mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ import { describe, expect, test } from "bun:test";
7
+
8
+ describe("nitpiq MCP server", () => {
9
+ test("keeps the store open after connect", async () => {
10
+ const repoRoot = mkdtempSync(path.join(tmpdir(), "nitpiq-mcp-"));
11
+ await Bun.write(path.join(repoRoot, "file.ts"), "export const value = 1;\n");
12
+
13
+ Bun.spawnSync(["git", "init"], { cwd: repoRoot, stdout: "ignore", stderr: "ignore" });
14
+
15
+ const dbDir = path.join(repoRoot, ".git", "nitpiq");
16
+ mkdirSync(dbDir, { recursive: true });
17
+
18
+ const client = new Client({ name: "nitpiq-test", version: "0.0.0" }, { capabilities: {} });
19
+ const transport = new StdioClientTransport({
20
+ command: "bun",
21
+ args: ["run", path.join(process.cwd(), "src/cli/nitpiq-mcp.ts"), repoRoot],
22
+ cwd: process.cwd(),
23
+ stderr: "pipe",
24
+ });
25
+
26
+ await client.connect(transport);
27
+
28
+ const result = await client.callTool({
29
+ name: "review_list_threads",
30
+ arguments: { status: "open" },
31
+ });
32
+
33
+ expect(result.isError).not.toBeTrue();
34
+
35
+ await transport.close();
36
+ });
37
+ });