@hienlh/ppm 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/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import simpleGit, { type SimpleGit } from "simple-git";
|
|
2
|
+
import type {
|
|
3
|
+
GitStatus,
|
|
4
|
+
GitFileChange,
|
|
5
|
+
GitCommit,
|
|
6
|
+
GitBranch,
|
|
7
|
+
GitGraphData,
|
|
8
|
+
} from "../types/git.ts";
|
|
9
|
+
|
|
10
|
+
class GitService {
|
|
11
|
+
private git(projectPath: string): SimpleGit {
|
|
12
|
+
return simpleGit(projectPath);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async status(projectPath: string): Promise<GitStatus> {
|
|
16
|
+
const git = this.git(projectPath);
|
|
17
|
+
const s = await git.status();
|
|
18
|
+
|
|
19
|
+
const staged: GitFileChange[] = [];
|
|
20
|
+
for (const f of s.staged) {
|
|
21
|
+
staged.push({ path: f, status: "M" });
|
|
22
|
+
}
|
|
23
|
+
for (const f of s.created) {
|
|
24
|
+
if (s.staged.includes(f)) {
|
|
25
|
+
// Override status for staged created files
|
|
26
|
+
const idx = staged.findIndex((x) => x.path === f);
|
|
27
|
+
if (idx >= 0) staged[idx]!.status = "A";
|
|
28
|
+
else staged.push({ path: f, status: "A" });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const f of s.deleted) {
|
|
32
|
+
if (s.staged.includes(f)) {
|
|
33
|
+
const idx = staged.findIndex((x) => x.path === f);
|
|
34
|
+
if (idx >= 0) staged[idx]!.status = "D";
|
|
35
|
+
else staged.push({ path: f, status: "D" });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const f of s.renamed) {
|
|
39
|
+
staged.push({ path: f.to, status: "R", oldPath: f.from });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build staged set from the raw diff result for accuracy
|
|
43
|
+
const stagedSet = new Set<string>();
|
|
44
|
+
const stagedFiles: GitFileChange[] = [];
|
|
45
|
+
try {
|
|
46
|
+
const diffStaged = await git.diff(["--cached", "--name-status"]);
|
|
47
|
+
for (const line of diffStaged.trim().split("\n")) {
|
|
48
|
+
if (!line) continue;
|
|
49
|
+
const parts = line.split("\t");
|
|
50
|
+
const statusChar = (parts[0] ?? "M").charAt(0) as GitFileChange["status"];
|
|
51
|
+
const filePath = parts[1] ?? "";
|
|
52
|
+
const oldPath = statusChar === "R" ? filePath : undefined;
|
|
53
|
+
const actualPath = statusChar === "R" ? (parts[2] ?? filePath) : filePath;
|
|
54
|
+
stagedSet.add(actualPath);
|
|
55
|
+
stagedFiles.push({ path: actualPath, status: statusChar, oldPath });
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Fallback: empty repo or no HEAD
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Unstaged changes
|
|
62
|
+
const unstaged: GitFileChange[] = [];
|
|
63
|
+
try {
|
|
64
|
+
const diffUnstaged = await git.diff(["--name-status"]);
|
|
65
|
+
for (const line of diffUnstaged.trim().split("\n")) {
|
|
66
|
+
if (!line) continue;
|
|
67
|
+
const parts = line.split("\t");
|
|
68
|
+
const statusChar = (parts[0] ?? "M").charAt(0) as GitFileChange["status"];
|
|
69
|
+
const filePath = parts[1] ?? "";
|
|
70
|
+
const oldPath = statusChar === "R" ? filePath : undefined;
|
|
71
|
+
const actualPath = statusChar === "R" ? (parts[2] ?? filePath) : filePath;
|
|
72
|
+
unstaged.push({ path: actualPath, status: statusChar, oldPath });
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// empty
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Untracked files (not in staged)
|
|
79
|
+
const untracked = s.not_added.filter((f) => !stagedSet.has(f));
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
current: s.current,
|
|
83
|
+
staged: stagedFiles.length > 0 ? stagedFiles : staged,
|
|
84
|
+
unstaged,
|
|
85
|
+
untracked,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async diff(
|
|
90
|
+
projectPath: string,
|
|
91
|
+
ref1?: string,
|
|
92
|
+
ref2?: string,
|
|
93
|
+
): Promise<string> {
|
|
94
|
+
const git = this.git(projectPath);
|
|
95
|
+
const args: string[] = [];
|
|
96
|
+
if (ref1) args.push(ref1);
|
|
97
|
+
if (ref2) args.push(ref2);
|
|
98
|
+
return git.diff(args);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async diffStat(
|
|
102
|
+
projectPath: string,
|
|
103
|
+
ref1?: string,
|
|
104
|
+
ref2?: string,
|
|
105
|
+
): Promise<Array<{ path: string; additions: number; deletions: number }>> {
|
|
106
|
+
const git = this.git(projectPath);
|
|
107
|
+
const args: string[] = ["--numstat"];
|
|
108
|
+
if (ref1) args.push(ref1);
|
|
109
|
+
if (ref2) args.push(ref2);
|
|
110
|
+
const raw = await git.diff(args);
|
|
111
|
+
const files: Array<{ path: string; additions: number; deletions: number }> = [];
|
|
112
|
+
for (const line of raw.trim().split("\n")) {
|
|
113
|
+
if (!line) continue;
|
|
114
|
+
const parts = line.split("\t");
|
|
115
|
+
const additions = parseInt(parts[0] ?? "0", 10) || 0;
|
|
116
|
+
const deletions = parseInt(parts[1] ?? "0", 10) || 0;
|
|
117
|
+
const path = parts[2] ?? "";
|
|
118
|
+
if (path) files.push({ path, additions, deletions });
|
|
119
|
+
}
|
|
120
|
+
return files;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async fileDiff(
|
|
124
|
+
projectPath: string,
|
|
125
|
+
filePath: string,
|
|
126
|
+
ref?: string,
|
|
127
|
+
): Promise<string> {
|
|
128
|
+
const git = this.git(projectPath);
|
|
129
|
+
const args: string[] = [];
|
|
130
|
+
if (ref) args.push(ref);
|
|
131
|
+
args.push("--", filePath);
|
|
132
|
+
return git.diff(args);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async stage(projectPath: string, files: string[]): Promise<void> {
|
|
136
|
+
await this.git(projectPath).add(files);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async unstage(projectPath: string, files: string[]): Promise<void> {
|
|
140
|
+
await this.git(projectPath).reset(["HEAD", "--", ...files]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async commit(projectPath: string, message: string): Promise<string> {
|
|
144
|
+
const result = await this.git(projectPath).commit(message);
|
|
145
|
+
return result.commit;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async push(
|
|
149
|
+
projectPath: string,
|
|
150
|
+
remote?: string,
|
|
151
|
+
branch?: string,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const args: string[] = [];
|
|
154
|
+
if (remote) args.push(remote);
|
|
155
|
+
if (branch) args.push(branch);
|
|
156
|
+
await this.git(projectPath).push(args);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async pull(
|
|
160
|
+
projectPath: string,
|
|
161
|
+
remote?: string,
|
|
162
|
+
branch?: string,
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
const args: string[] = [];
|
|
165
|
+
if (remote) args.push(remote);
|
|
166
|
+
if (branch) args.push(branch);
|
|
167
|
+
await this.git(projectPath).pull(args);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async branches(projectPath: string): Promise<GitBranch[]> {
|
|
171
|
+
const git = this.git(projectPath);
|
|
172
|
+
const summary = await git.branch(["-a", "--no-color"]);
|
|
173
|
+
return Object.entries(summary.branches).map(([name, info]) => ({
|
|
174
|
+
name,
|
|
175
|
+
current: info.current,
|
|
176
|
+
remote: name.startsWith("remotes/"),
|
|
177
|
+
commitHash: info.commit,
|
|
178
|
+
ahead: 0,
|
|
179
|
+
behind: 0,
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async createBranch(
|
|
184
|
+
projectPath: string,
|
|
185
|
+
name: string,
|
|
186
|
+
from?: string,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const args = [name];
|
|
189
|
+
if (from) args.push(from);
|
|
190
|
+
await this.git(projectPath).checkoutBranch(name, from ?? "HEAD");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async checkout(projectPath: string, ref: string): Promise<void> {
|
|
194
|
+
await this.git(projectPath).checkout(ref);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async deleteBranch(
|
|
198
|
+
projectPath: string,
|
|
199
|
+
name: string,
|
|
200
|
+
force = false,
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
const flag = force ? "-D" : "-d";
|
|
203
|
+
await this.git(projectPath).branch([flag, name]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async merge(projectPath: string, source: string): Promise<void> {
|
|
207
|
+
await this.git(projectPath).merge([source]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async graphData(
|
|
211
|
+
projectPath: string,
|
|
212
|
+
maxCount = 200,
|
|
213
|
+
): Promise<GitGraphData> {
|
|
214
|
+
const git = this.git(projectPath);
|
|
215
|
+
|
|
216
|
+
// Use simple-git built-in log
|
|
217
|
+
const log = await git.log({ "--all": null, maxCount });
|
|
218
|
+
|
|
219
|
+
const commits: GitCommit[] = log.all.map((c) => ({
|
|
220
|
+
hash: c.hash,
|
|
221
|
+
abbreviatedHash: c.hash.slice(0, 7),
|
|
222
|
+
subject: c.message,
|
|
223
|
+
body: c.body,
|
|
224
|
+
authorName: c.author_name,
|
|
225
|
+
authorEmail: c.author_email,
|
|
226
|
+
authorDate: c.date,
|
|
227
|
+
parents: [],
|
|
228
|
+
refs: c.refs ? c.refs.split(", ").filter(Boolean) : [],
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
// Get parent hashes via raw format
|
|
232
|
+
try {
|
|
233
|
+
const parentLog = await git.raw([
|
|
234
|
+
"log",
|
|
235
|
+
"--all",
|
|
236
|
+
`--max-count=${maxCount}`,
|
|
237
|
+
"--format=%H %P",
|
|
238
|
+
]);
|
|
239
|
+
const parentMap = new Map<string, string[]>();
|
|
240
|
+
for (const line of parentLog.trim().split("\n")) {
|
|
241
|
+
if (!line) continue;
|
|
242
|
+
const [hash, ...parents] = line.split(" ");
|
|
243
|
+
if (hash) parentMap.set(hash, parents.filter(Boolean));
|
|
244
|
+
}
|
|
245
|
+
for (const c of commits) {
|
|
246
|
+
c.parents = parentMap.get(c.hash) ?? [];
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// May fail on empty repo
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const branchSummary = await git.branch(["-a", "--no-color"]);
|
|
253
|
+
|
|
254
|
+
// simple-git branch().commit returns abbreviated hash — map to full hash
|
|
255
|
+
const abbrToFull = new Map<string, string>();
|
|
256
|
+
for (const c of commits) {
|
|
257
|
+
abbrToFull.set(c.abbreviatedHash, c.hash);
|
|
258
|
+
// Also index with longer prefixes for safety
|
|
259
|
+
abbrToFull.set(c.hash.slice(0, 8), c.hash);
|
|
260
|
+
abbrToFull.set(c.hash.slice(0, 10), c.hash);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const branches: GitBranch[] = Object.entries(branchSummary.branches).map(
|
|
264
|
+
([name, info]) => ({
|
|
265
|
+
name,
|
|
266
|
+
current: info.current,
|
|
267
|
+
remote: name.startsWith("remotes/"),
|
|
268
|
+
commitHash: abbrToFull.get(info.commit) ?? info.commit,
|
|
269
|
+
ahead: 0,
|
|
270
|
+
behind: 0,
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return { commits, branches };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async discardChanges(projectPath: string, files: string[]): Promise<void> {
|
|
278
|
+
const git = this.git(projectPath);
|
|
279
|
+
// Separate tracked vs untracked files
|
|
280
|
+
const s = await git.status();
|
|
281
|
+
const untrackedSet = new Set(s.not_added);
|
|
282
|
+
const tracked = files.filter((f) => !untrackedSet.has(f));
|
|
283
|
+
const untracked = files.filter((f) => untrackedSet.has(f));
|
|
284
|
+
|
|
285
|
+
if (tracked.length > 0) {
|
|
286
|
+
await git.checkout(["--", ...tracked]);
|
|
287
|
+
}
|
|
288
|
+
if (untracked.length > 0) {
|
|
289
|
+
await git.clean("f", ["-e", "!.*", "--", ...untracked]);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async cherryPick(projectPath: string, hash: string): Promise<void> {
|
|
294
|
+
await this.git(projectPath).raw(["cherry-pick", hash]);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async revert(projectPath: string, hash: string): Promise<void> {
|
|
298
|
+
await this.git(projectPath).raw(["revert", "--no-edit", hash]);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async createTag(
|
|
302
|
+
projectPath: string,
|
|
303
|
+
name: string,
|
|
304
|
+
hash?: string,
|
|
305
|
+
): Promise<void> {
|
|
306
|
+
const args = ["tag", name];
|
|
307
|
+
if (hash) args.push(hash);
|
|
308
|
+
await this.git(projectPath).raw(args);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async getCreatePrUrl(
|
|
312
|
+
projectPath: string,
|
|
313
|
+
branch: string,
|
|
314
|
+
): Promise<string | null> {
|
|
315
|
+
try {
|
|
316
|
+
const git = this.git(projectPath);
|
|
317
|
+
const remotes = await git.getRemotes(true);
|
|
318
|
+
const origin = remotes.find((r) => r.name === "origin");
|
|
319
|
+
if (!origin) return null;
|
|
320
|
+
|
|
321
|
+
const url = origin.refs.push || origin.refs.fetch;
|
|
322
|
+
if (!url) return null;
|
|
323
|
+
|
|
324
|
+
// Parse GitHub/GitLab URL
|
|
325
|
+
const parsed = this.parseRemoteUrl(url);
|
|
326
|
+
if (!parsed) return null;
|
|
327
|
+
|
|
328
|
+
const encodedBranch = encodeURIComponent(branch);
|
|
329
|
+
if (parsed.host.includes("github")) {
|
|
330
|
+
return `https://${parsed.host}/${parsed.owner}/${parsed.repo}/compare/${encodedBranch}?expand=1`;
|
|
331
|
+
}
|
|
332
|
+
if (parsed.host.includes("gitlab")) {
|
|
333
|
+
return `https://${parsed.host}/${parsed.owner}/${parsed.repo}/-/merge_requests/new?merge_request[source_branch]=${encodedBranch}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return null;
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private parseRemoteUrl(
|
|
343
|
+
url: string,
|
|
344
|
+
): { host: string; owner: string; repo: string } | null {
|
|
345
|
+
// SSH: git@github.com:owner/repo.git
|
|
346
|
+
const sshMatch = url.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/);
|
|
347
|
+
if (sshMatch) {
|
|
348
|
+
return {
|
|
349
|
+
host: sshMatch[1]!,
|
|
350
|
+
owner: sshMatch[2]!,
|
|
351
|
+
repo: sshMatch[3]!,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
355
|
+
try {
|
|
356
|
+
const parsed = new URL(url);
|
|
357
|
+
const parts = parsed.pathname.replace(/^\//, "").replace(/\.git$/, "").split("/");
|
|
358
|
+
if (parts.length >= 2) {
|
|
359
|
+
return {
|
|
360
|
+
host: parsed.host,
|
|
361
|
+
owner: parts[0]!,
|
|
362
|
+
repo: parts[1]!,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// not a URL
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export const gitService = new GitService();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve, basename, join } from "node:path";
|
|
3
|
+
import { configService } from "./config.service.ts";
|
|
4
|
+
import type { ProjectConfig } from "../types/config.ts";
|
|
5
|
+
import type { ProjectInfo } from "../types/project.ts";
|
|
6
|
+
|
|
7
|
+
const MAX_SCAN_DEPTH = 3;
|
|
8
|
+
|
|
9
|
+
class ProjectService {
|
|
10
|
+
/** List all registered projects with optional git info */
|
|
11
|
+
list(): ProjectInfo[] {
|
|
12
|
+
const projects = configService.get("projects");
|
|
13
|
+
return projects.map((p) => ({
|
|
14
|
+
name: p.name,
|
|
15
|
+
path: p.path,
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Add a project by path. Auto-derives name from folder if not given. */
|
|
20
|
+
add(projectPath: string, name?: string): ProjectConfig {
|
|
21
|
+
const abs = resolve(projectPath);
|
|
22
|
+
if (!existsSync(abs)) {
|
|
23
|
+
throw new Error(`Path does not exist: ${abs}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const projects = configService.get("projects");
|
|
27
|
+
const projectName = name ?? basename(abs);
|
|
28
|
+
|
|
29
|
+
// Check duplicates
|
|
30
|
+
if (projects.some((p) => p.name === projectName)) {
|
|
31
|
+
throw new Error(`Project "${projectName}" already exists`);
|
|
32
|
+
}
|
|
33
|
+
if (projects.some((p) => resolve(p.path) === abs)) {
|
|
34
|
+
throw new Error(`Path "${abs}" already registered`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const entry: ProjectConfig = { path: abs, name: projectName };
|
|
38
|
+
configService.set("projects", [...projects, entry]);
|
|
39
|
+
configService.save();
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Remove a project by name or path */
|
|
44
|
+
remove(nameOrPath: string): void {
|
|
45
|
+
const projects = configService.get("projects");
|
|
46
|
+
const abs = resolve(nameOrPath);
|
|
47
|
+
const filtered = projects.filter(
|
|
48
|
+
(p) => p.name !== nameOrPath && resolve(p.path) !== abs,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (filtered.length === projects.length) {
|
|
52
|
+
throw new Error(`Project not found: ${nameOrPath}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
configService.set("projects", filtered);
|
|
56
|
+
configService.save();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve a project by name or path */
|
|
60
|
+
resolve(nameOrPath: string): ProjectConfig {
|
|
61
|
+
const projects = configService.get("projects");
|
|
62
|
+
|
|
63
|
+
// Try name first
|
|
64
|
+
const byName = projects.find((p) => p.name === nameOrPath);
|
|
65
|
+
if (byName) return byName;
|
|
66
|
+
|
|
67
|
+
// Try path
|
|
68
|
+
const abs = resolve(nameOrPath);
|
|
69
|
+
const byPath = projects.find((p) => resolve(p.path) === abs);
|
|
70
|
+
if (byPath) return byPath;
|
|
71
|
+
|
|
72
|
+
throw new Error(`Project not found: ${nameOrPath}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Scan a directory recursively for .git folders (up to MAX_SCAN_DEPTH) */
|
|
76
|
+
scanForGitRepos(dir: string, depth = 0): string[] {
|
|
77
|
+
const results: string[] = [];
|
|
78
|
+
const abs = resolve(dir);
|
|
79
|
+
|
|
80
|
+
if (depth > MAX_SCAN_DEPTH || !existsSync(abs)) return results;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const entries = readdirSync(abs, { withFileTypes: true });
|
|
84
|
+
|
|
85
|
+
// Check if this directory itself is a git repo
|
|
86
|
+
if (entries.some((e) => e.name === ".git" && e.isDirectory())) {
|
|
87
|
+
results.push(abs);
|
|
88
|
+
return results; // Don't scan inside git repos
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Recurse into subdirectories
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (!entry.isDirectory()) continue;
|
|
94
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
results.push(...this.scanForGitRepos(join(abs, entry.name), depth + 1));
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Permission denied or other FS error — skip
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const projectService = new ProjectService();
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { resolve, basename, relative, sep } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
|
|
6
|
+
export interface SlashItem {
|
|
7
|
+
type: "skill" | "command";
|
|
8
|
+
/** Slash name, e.g. "review", "devops/deploy", "ck:research" */
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
argumentHint?: string;
|
|
12
|
+
/** Where the item comes from */
|
|
13
|
+
scope: "project" | "user";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Safely coerce a frontmatter value to string, returns undefined if not a scalar. */
|
|
17
|
+
function str(val: unknown): string | undefined {
|
|
18
|
+
if (typeof val === "string") return val;
|
|
19
|
+
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse YAML frontmatter from a Markdown file.
|
|
25
|
+
*/
|
|
26
|
+
function parseFrontmatter(content: string): { meta: Record<string, unknown>; body: string } {
|
|
27
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
28
|
+
if (!match || !match[1]) return { meta: {}, body: content };
|
|
29
|
+
try {
|
|
30
|
+
const meta = yaml.load(match[1]) as Record<string, unknown>;
|
|
31
|
+
const body = content.slice(match[0]!.length).trim();
|
|
32
|
+
return { meta: meta ?? {}, body };
|
|
33
|
+
} catch {
|
|
34
|
+
return { meta: {}, body: content };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Recursively walk a directory tree, calling `visitor` for every file.
|
|
40
|
+
* Ignores unreadable dirs/files silently.
|
|
41
|
+
*/
|
|
42
|
+
function walkDir(dir: string, visitor: (filePath: string) => void): void {
|
|
43
|
+
let entries: string[];
|
|
44
|
+
try {
|
|
45
|
+
entries = readdirSync(dir);
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const full = resolve(dir, entry);
|
|
51
|
+
try {
|
|
52
|
+
const stat = statSync(full);
|
|
53
|
+
if (stat.isDirectory()) {
|
|
54
|
+
walkDir(full, visitor);
|
|
55
|
+
} else if (stat.isFile()) {
|
|
56
|
+
visitor(full);
|
|
57
|
+
}
|
|
58
|
+
} catch { /* skip */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Collect commands from a commands directory (recursive).
|
|
64
|
+
* `commands/devops/deploy.md` → name `devops/deploy`
|
|
65
|
+
*/
|
|
66
|
+
function collectCommands(commandsDir: string, scope: "project" | "user"): SlashItem[] {
|
|
67
|
+
const items: SlashItem[] = [];
|
|
68
|
+
if (!existsSync(commandsDir)) return items;
|
|
69
|
+
walkDir(commandsDir, (filePath) => {
|
|
70
|
+
if (!filePath.endsWith(".md")) return;
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(filePath, "utf-8");
|
|
73
|
+
const { meta } = parseFrontmatter(content);
|
|
74
|
+
const rel = relative(commandsDir, filePath);
|
|
75
|
+
const name = rel.replace(/\.md$/, "").split(sep).join("/");
|
|
76
|
+
items.push({
|
|
77
|
+
type: "command",
|
|
78
|
+
name: str(meta.name) ?? name,
|
|
79
|
+
description: str(meta.description) ?? "",
|
|
80
|
+
argumentHint: str(meta["argument-hint"]),
|
|
81
|
+
scope,
|
|
82
|
+
});
|
|
83
|
+
} catch { /* skip */ }
|
|
84
|
+
});
|
|
85
|
+
return items;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collect skills from a skills directory.
|
|
90
|
+
*
|
|
91
|
+
* @param skillsDir Root skills directory to scan
|
|
92
|
+
* @param scope "project" or "user"
|
|
93
|
+
* @param strictMode When true, ONLY pick up SKILL.md files (used for user-global
|
|
94
|
+
* which can have many supporting .md files per skill).
|
|
95
|
+
* When false, also pick up loose .md files outside SKILL.md dirs
|
|
96
|
+
* (used for project-local where flat layout is common).
|
|
97
|
+
*/
|
|
98
|
+
function collectSkills(skillsDir: string, scope: "project" | "user", strictMode: boolean): SlashItem[] {
|
|
99
|
+
const items: SlashItem[] = [];
|
|
100
|
+
if (!existsSync(skillsDir)) return items;
|
|
101
|
+
|
|
102
|
+
const dirsWithSkillMd = new Set<string>();
|
|
103
|
+
|
|
104
|
+
// Pass 1: SKILL.md files (directory-based skills)
|
|
105
|
+
walkDir(skillsDir, (filePath) => {
|
|
106
|
+
if (basename(filePath) !== "SKILL.md") return;
|
|
107
|
+
try {
|
|
108
|
+
const content = readFileSync(filePath, "utf-8");
|
|
109
|
+
const { meta } = parseFrontmatter(content);
|
|
110
|
+
const skillDir = resolve(filePath, "..");
|
|
111
|
+
dirsWithSkillMd.add(skillDir);
|
|
112
|
+
const rel = relative(skillsDir, skillDir);
|
|
113
|
+
const pathName = rel.split(sep).join("/");
|
|
114
|
+
const name = str(meta.name) ?? pathName;
|
|
115
|
+
if (!name) return;
|
|
116
|
+
items.push({
|
|
117
|
+
type: "skill",
|
|
118
|
+
name,
|
|
119
|
+
description: str(meta.description) ?? "",
|
|
120
|
+
scope,
|
|
121
|
+
});
|
|
122
|
+
} catch { /* skip */ }
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Pass 2 (only in relaxed mode): loose .md files not inside a SKILL.md directory
|
|
126
|
+
if (!strictMode) {
|
|
127
|
+
walkDir(skillsDir, (filePath) => {
|
|
128
|
+
if (!filePath.endsWith(".md")) return;
|
|
129
|
+
if (basename(filePath) === "SKILL.md") return;
|
|
130
|
+
const dir = resolve(filePath, "..");
|
|
131
|
+
// Skip supporting files inside a skill dir (or any ancestor)
|
|
132
|
+
let ancestor = dir;
|
|
133
|
+
while (ancestor.startsWith(skillsDir) && ancestor !== skillsDir) {
|
|
134
|
+
if (dirsWithSkillMd.has(ancestor)) return;
|
|
135
|
+
ancestor = resolve(ancestor, "..");
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const content = readFileSync(filePath, "utf-8");
|
|
139
|
+
const { meta } = parseFrontmatter(content);
|
|
140
|
+
const rel = relative(skillsDir, filePath);
|
|
141
|
+
const pathName = rel.replace(/\.md$/, "").split(sep).join("/");
|
|
142
|
+
const name = str(meta.name) ?? pathName;
|
|
143
|
+
if (!name) return;
|
|
144
|
+
items.push({
|
|
145
|
+
type: "skill",
|
|
146
|
+
name,
|
|
147
|
+
description: str(meta.description) ?? "",
|
|
148
|
+
scope,
|
|
149
|
+
});
|
|
150
|
+
} catch { /* skip */ }
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return items;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Scan for available slash commands and skills.
|
|
159
|
+
*
|
|
160
|
+
* Sources (merged, project overrides user if same name):
|
|
161
|
+
* 1. User-global: ~/.claude/commands/ and ~/.claude/skills/ (strict: SKILL.md only)
|
|
162
|
+
* 2. Project-local: <projectPath>/.claude/commands/ and .claude/skills/ (relaxed: also loose .md)
|
|
163
|
+
*/
|
|
164
|
+
export function listSlashItems(projectPath: string): SlashItem[] {
|
|
165
|
+
const home = homedir();
|
|
166
|
+
const globalClaude = resolve(home, ".claude");
|
|
167
|
+
|
|
168
|
+
// Collect from both scopes (user-global uses strict mode)
|
|
169
|
+
const userCommands = collectCommands(resolve(globalClaude, "commands"), "user");
|
|
170
|
+
const userSkills = collectSkills(resolve(globalClaude, "skills"), "user", true);
|
|
171
|
+
const projectCommands = collectCommands(resolve(projectPath, ".claude", "commands"), "project");
|
|
172
|
+
const projectSkills = collectSkills(resolve(projectPath, ".claude", "skills"), "project", false);
|
|
173
|
+
|
|
174
|
+
// Merge: project items override user items with the same name
|
|
175
|
+
const map = new Map<string, SlashItem>();
|
|
176
|
+
for (const item of [...userCommands, ...userSkills]) {
|
|
177
|
+
map.set(`${item.type}:${item.name}`, item);
|
|
178
|
+
}
|
|
179
|
+
for (const item of [...projectCommands, ...projectSkills]) {
|
|
180
|
+
map.set(`${item.type}:${item.name}`, item);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return Array.from(map.values());
|
|
184
|
+
}
|