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 +173 -0
- package/bin/nitpiq-mcp.ts +2 -0
- package/bin/nitpiq.ts +2 -0
- package/bunfig.toml +1 -0
- package/package.json +44 -0
- package/plugins/react-compiler.ts +28 -0
- package/src/cli/nitpiq-mcp.ts +10 -0
- package/src/cli/nitpiq.tsx +36 -0
- package/src/git/repo.ts +237 -0
- package/src/log/log.ts +27 -0
- package/src/mcp/server.test.ts +37 -0
- package/src/mcp/server.ts +210 -0
- package/src/review/anchor.ts +118 -0
- package/src/review/types.ts +64 -0
- package/src/store/store.ts +315 -0
- package/src/tui/app.tsx +1089 -0
- package/src/tui/demo.ts +241 -0
- package/src/tui/diff.ts +99 -0
- package/src/tui/highlight.ts +208 -0
- package/src/tui/theme.ts +107 -0
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
|
+
```
|
package/bin/nitpiq.ts
ADDED
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,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
|
+
}
|
package/src/git/repo.ts
ADDED
|
@@ -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
|
+
});
|