@civilization/dsh-git-workbench 0.0.0-stage → 0.4.1

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/src/index.ts ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Host entry for the Git Workbench & source control plugin.
3
+ * Provides RPC endpoints for the Client right-sidebar viewer, status bar, and model tools for Agents.
4
+ */
5
+
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import {
8
+ getGitAllBranches,
9
+ getGitCommitDetails,
10
+ getGitDiff,
11
+ getGitLog,
12
+ getGitStatus,
13
+ getGitTags,
14
+ gitCheckout,
15
+ gitCommit,
16
+ gitCreateTag,
17
+ gitDeleteTag,
18
+ gitDiscard,
19
+ gitFetch,
20
+ gitPull,
21
+ gitPush,
22
+ gitStage,
23
+ gitUnstage,
24
+ } from './git-executor.ts'
25
+ import { registerGitTool } from './tool.ts'
26
+
27
+ export type * from './types.ts'
28
+
29
+ /** Cordis plugin name */
30
+ export const name = '@civilization/dsh-git-workbench'
31
+
32
+ /** Required host services */
33
+ export const inject = ['webServer', 'sessions', 'sandboxPolicy']
34
+
35
+ interface HostServices {
36
+ webServer?: {
37
+ register: (route: unknown) => () => void
38
+ }
39
+ sessions?: {
40
+ get: (id: string) => { header?: { cwd?: string } } | undefined
41
+ }
42
+ sandboxPolicy?: {
43
+ workspaceRoot?: string
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Execute Git operations based on requested endpoint and payload.
49
+ */
50
+ export async function executeGitEndpoint(
51
+ endpoint: string,
52
+ payload: unknown,
53
+ resolveTargetDir: (sessionId?: string) => string,
54
+ ): Promise<{ ok: true; value: unknown } | { ok: false; error: { code: string; message: string; details: Record<string, unknown> } }> {
55
+ try {
56
+ const body = (typeof payload === 'object' && payload !== null ? payload : {}) as Record<string, unknown>
57
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
58
+ const cwd = typeof body.path === 'string' ? body.path : resolveTargetDir(sessionId)
59
+
60
+ if (endpoint === 'status') {
61
+ const status = await getGitStatus(cwd)
62
+ return { ok: true, value: status }
63
+ }
64
+
65
+ if (endpoint === 'log') {
66
+ const maxCount = typeof body.maxCount === 'number' ? body.maxCount : 50
67
+ const commits = await getGitLog(cwd, maxCount)
68
+ return { ok: true, value: commits }
69
+ }
70
+
71
+ if (endpoint === 'commitDetails') {
72
+ const commit = typeof body.commit === 'string' ? body.commit : ''
73
+ const details = await getGitCommitDetails(cwd, commit)
74
+ return { ok: true, value: details }
75
+ }
76
+
77
+ if (endpoint === 'diff') {
78
+ const path = typeof body.filePath === 'string' ? body.filePath : undefined
79
+ const staged = typeof body.staged === 'boolean' ? body.staged : false
80
+ const commit = typeof body.commit === 'string' ? body.commit : undefined
81
+ const diff = await getGitDiff(cwd, {
82
+ ...path !== undefined ? { path } : {},
83
+ staged,
84
+ ...commit !== undefined ? { commit } : {},
85
+ })
86
+ return { ok: true, value: diff }
87
+ }
88
+
89
+ if (endpoint === 'branches') {
90
+ const branches = await getGitAllBranches(cwd)
91
+ return { ok: true, value: branches }
92
+ }
93
+
94
+ if (endpoint === 'checkout') {
95
+ const branch = typeof body.branch === 'string' ? body.branch : ''
96
+ const createNew = typeof body.create === 'boolean' ? body.create : false
97
+ const result = await gitCheckout(cwd, branch, createNew)
98
+ return { ok: true, value: result }
99
+ }
100
+
101
+ if (endpoint === 'stage') {
102
+ const paths = Array.isArray(body.paths)
103
+ ? (body.paths as string[])
104
+ : body.paths === 'all'
105
+ ? 'all'
106
+ : typeof body.path === 'string'
107
+ ? [body.path]
108
+ : 'all'
109
+ const result = await gitStage(cwd, paths)
110
+ return { ok: true, value: result }
111
+ }
112
+
113
+ if (endpoint === 'unstage') {
114
+ const paths = Array.isArray(body.paths)
115
+ ? (body.paths as string[])
116
+ : body.paths === 'all'
117
+ ? 'all'
118
+ : typeof body.path === 'string'
119
+ ? [body.path]
120
+ : 'all'
121
+ const result = await gitUnstage(cwd, paths)
122
+ return { ok: true, value: result }
123
+ }
124
+
125
+ if (endpoint === 'discard') {
126
+ const paths = Array.isArray(body.paths)
127
+ ? (body.paths as string[])
128
+ : typeof body.path === 'string'
129
+ ? [body.path]
130
+ : []
131
+ const result = await gitDiscard(cwd, paths)
132
+ return { ok: true, value: result }
133
+ }
134
+
135
+ if (endpoint === 'commit') {
136
+ const message = typeof body.message === 'string' ? body.message : ''
137
+ const result = await gitCommit(cwd, message)
138
+ return { ok: true, value: result }
139
+ }
140
+
141
+ if (endpoint === 'fetch') {
142
+ const result = await gitFetch(cwd)
143
+ return { ok: true, value: result }
144
+ }
145
+
146
+ if (endpoint === 'pull') {
147
+ const result = await gitPull(cwd)
148
+ return { ok: true, value: result }
149
+ }
150
+
151
+ if (endpoint === 'push') {
152
+ const result = await gitPush(cwd)
153
+ return { ok: true, value: result }
154
+ }
155
+
156
+ if (endpoint === 'tags') {
157
+ const tags = await getGitTags(cwd)
158
+ return { ok: true, value: tags }
159
+ }
160
+
161
+ if (endpoint === 'createTag') {
162
+ const name = typeof body.name === 'string' ? body.name : ''
163
+ const commit = typeof body.commit === 'string' ? body.commit : undefined
164
+ const message = typeof body.message === 'string' ? body.message : undefined
165
+ const result = await gitCreateTag(cwd, name, commit, message)
166
+ return { ok: true, value: result }
167
+ }
168
+
169
+ if (endpoint === 'deleteTag') {
170
+ const name = typeof body.name === 'string' ? body.name : ''
171
+ const result = await gitDeleteTag(cwd, name)
172
+ return { ok: true, value: result }
173
+ }
174
+
175
+ return {
176
+ ok: false,
177
+ error: {
178
+ code: 'git/not_found',
179
+ message: `Unknown git RPC endpoint: ${endpoint}`,
180
+ details: {},
181
+ },
182
+ }
183
+ } catch (error) {
184
+ return {
185
+ ok: false,
186
+ error: {
187
+ code: 'git/error',
188
+ message: error instanceof Error ? error.message : String(error),
189
+ details: {},
190
+ },
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Apply the Git host plugin.
197
+ * Sets up direct WebServer HTTP route handlers under `/git/*` and registers the model tool.
198
+ * @param ctx - Context carrying the webServer, sessions, and sandboxPolicy services.
199
+ */
200
+ export function apply(ctx: Context): void {
201
+ const host = ctx as any as HostServices
202
+
203
+ const resolveTargetDir = (sessionId?: string): string => {
204
+ try {
205
+ if (sessionId) {
206
+ const liveCwd = host.sessions?.get?.(sessionId)?.header?.cwd
207
+ if (liveCwd) return liveCwd
208
+ }
209
+ } catch {}
210
+
211
+ try {
212
+ const workspaceRoot = host.sandboxPolicy?.workspaceRoot
213
+ if (workspaceRoot) return workspaceRoot
214
+ } catch {}
215
+
216
+ return process.cwd()
217
+ }
218
+
219
+ // 1. Direct WebServer route registration (Standard, robust DSH plugin route pattern)
220
+ const webServer = (ctx as any).webServer
221
+ if (webServer?.register) {
222
+ ctx.effect(() => webServer.register({
223
+ kind: 'prefix',
224
+ path: '/git',
225
+ handler: async (req: any, res: any) => {
226
+ try {
227
+ if (req.method === 'OPTIONS') {
228
+ res.writeHead(204, {
229
+ 'Access-Control-Allow-Origin': '*',
230
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
231
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
232
+ })
233
+ return res.end()
234
+ }
235
+
236
+ if (req.method !== 'POST') {
237
+ res.writeHead(405, { 'Content-Type': 'application/json' })
238
+ return res.end(JSON.stringify({ ok: false, error: { message: 'Method Not Allowed' } }))
239
+ }
240
+
241
+ const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
242
+ const match = pathname.match(/^\/git\/([a-zA-Z0-9_-]+)/)
243
+ const endpoint = match ? match[1] : undefined
244
+
245
+ if (!endpoint) {
246
+ res.writeHead(404, { 'Content-Type': 'application/json' })
247
+ return res.end(JSON.stringify({ ok: false, error: { message: 'Not Found' } }))
248
+ }
249
+
250
+ let rawBody = ''
251
+ req.on('data', (chunk: Buffer) => { rawBody += chunk.toString('utf8') })
252
+ req.on('end', async () => {
253
+ try {
254
+ let body: any = {}
255
+ try {
256
+ if (rawBody.trim()) body = JSON.parse(rawBody)
257
+ } catch {
258
+ res.writeHead(400, { 'Content-Type': 'application/json' })
259
+ return res.end(JSON.stringify({ ok: false, error: { message: 'Invalid JSON body' } }))
260
+ }
261
+
262
+ const rpcId = body?.type === 'client-request' ? body.rpcId : undefined
263
+ const payload = body?.type === 'client-request' ? (body.payload ?? {}) : body
264
+
265
+ const result = await executeGitEndpoint(endpoint, payload, resolveTargetDir)
266
+
267
+ res.writeHead(200, {
268
+ 'Content-Type': 'application/json; charset=utf-8',
269
+ 'Access-Control-Allow-Origin': '*',
270
+ })
271
+
272
+ if (rpcId !== undefined) {
273
+ res.end(JSON.stringify({
274
+ type: 'server-response',
275
+ rpcId,
276
+ result,
277
+ }))
278
+ } else {
279
+ res.end(JSON.stringify(result))
280
+ }
281
+ } catch (err: any) {
282
+ res.writeHead(500, { 'Content-Type': 'application/json' })
283
+ res.end(JSON.stringify({
284
+ ok: false,
285
+ error: { code: 'git/internal_error', message: err?.message ?? String(err) },
286
+ }))
287
+ }
288
+ })
289
+ } catch (err: any) {
290
+ try {
291
+ res.writeHead(500, { 'Content-Type': 'application/json' })
292
+ res.end(JSON.stringify({
293
+ ok: false,
294
+ error: { code: 'git/internal_error', message: err?.message ?? String(err) },
295
+ }))
296
+ } catch {}
297
+ }
298
+ },
299
+ }), 'git-workbench: /git routes')
300
+ }
301
+
302
+ // 2. Register the agent tool
303
+ registerGitTool(ctx, resolveTargetDir)
304
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Agent model tool for inspecting Git repository status, commits, and diffs.
3
+ */
4
+
5
+ import type { Context } from '@deepseek-ai/cordis'
6
+ import { defineTool } from '@deepseek-ai/dsh-tools'
7
+
8
+ export type JsonValue =
9
+ | string
10
+ | number
11
+ | boolean
12
+ | null
13
+ | { [key: string]: JsonValue }
14
+ | JsonValue[]
15
+ import {
16
+ getGitAllBranches,
17
+ getGitCommitDetails,
18
+ getGitDiff,
19
+ getGitLog,
20
+ getGitStatus,
21
+ getGitTags,
22
+ gitCheckout,
23
+ gitCreateTag,
24
+ gitDeleteTag,
25
+ } from './git-executor.ts'
26
+
27
+ /**
28
+ * Register git model tools (git_view and git_checkout) in the tools service.
29
+ * @param ctx - Context carrying the tools service.
30
+ * @param resolveDir - Resolver for the current agent or session working directory.
31
+ */
32
+ export function registerGitTool(ctx: Context, resolveDir: (sessionId?: string) => string): void {
33
+ ctx.inject(['tools'], (scope) => {
34
+ // 1. Tool for inspecting git status, DAG history, diff, and branches
35
+ scope.effect(() => scope.tools.register(defineTool({
36
+ name: 'git_view',
37
+ description: 'Inspect the current project Git repository status, changed files, DAG commit tree, branch list, or code diffs.',
38
+ parameters: {
39
+ action: {
40
+ type: 'string',
41
+ enum: ['status', 'log', 'diff', 'branches', 'commit', 'tags'],
42
+ description: 'The Git query action: "status" (changes and branch info), "log" (commits with DAG topology rails), "diff" (code diffs), "branches" (branch list), "commit" (commit details), or "tags" (all repository tags).',
43
+ },
44
+ path: {
45
+ type: 'string',
46
+ description: 'Optional file path filter for diff or status inspection.',
47
+ },
48
+ staged: {
49
+ type: 'boolean',
50
+ description: 'Whether to check staged changes when viewing diff (defaults to false for working tree changes).',
51
+ },
52
+ maxCount: {
53
+ type: 'number',
54
+ description: 'Maximum number of commits to retrieve when action is "log" (defaults to 30).',
55
+ },
56
+ commit: {
57
+ type: 'string',
58
+ description: 'Specific commit hash to inspect diff or details for.',
59
+ },
60
+ },
61
+ output: {
62
+ schema: {
63
+ type: 'object',
64
+ additionalProperties: true,
65
+ },
66
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
67
+ },
68
+ async execute(args, exec): Promise<Record<string, JsonValue>> {
69
+ const cwd = resolveDir(exec.agent?.session.id)
70
+ const action = args.action ?? 'status'
71
+
72
+ switch (action) {
73
+ case 'log': {
74
+ const rawCommits = await getGitLog(cwd, args.maxCount ?? 30)
75
+ const commits = rawCommits.map(c => ({
76
+ hash: c.hash,
77
+ shortHash: c.shortHash,
78
+ author: c.author,
79
+ authorEmail: c.authorEmail,
80
+ relativeDate: c.relativeDate,
81
+ date: c.date,
82
+ subject: c.subject,
83
+ parents: c.parents ? [...c.parents] : [],
84
+ refs: c.refs ? [...c.refs] : [],
85
+ ...c.lane !== undefined ? { lane: c.lane } : {},
86
+ ...c.laneColor !== undefined ? { laneColor: c.laneColor } : {},
87
+ }))
88
+ return { action: 'log', count: commits.length, commits }
89
+ }
90
+ case 'commit': {
91
+ if (!args.commit) {
92
+ return { action: 'commit', error: 'Missing commit parameter' }
93
+ }
94
+ const details = await getGitCommitDetails(cwd, args.commit)
95
+ if (!details) return { action: 'commit', found: false }
96
+ return {
97
+ action: 'commit',
98
+ found: true,
99
+ commit: {
100
+ hash: details.commit.hash,
101
+ shortHash: details.commit.shortHash,
102
+ author: details.commit.author,
103
+ date: details.commit.date,
104
+ subject: details.commit.subject,
105
+ },
106
+ files: details.files.map(f => ({
107
+ path: f.path,
108
+ status: f.status,
109
+ additions: f.additions,
110
+ deletions: f.deletions,
111
+ })),
112
+ totalAdditions: details.totalAdditions,
113
+ totalDeletions: details.totalDeletions,
114
+ }
115
+ }
116
+ case 'diff': {
117
+ const diff = await getGitDiff(cwd, {
118
+ ...args.path !== undefined ? { path: args.path } : {},
119
+ ...args.staged !== undefined ? { staged: args.staged } : {},
120
+ ...args.commit !== undefined ? { commit: args.commit } : {},
121
+ })
122
+ const files = diff.files.map(f => ({ path: f.path, additions: f.additions, deletions: f.deletions }))
123
+ return { action: 'diff', files, rawDiff: diff.rawDiff }
124
+ }
125
+ case 'branches': {
126
+ const summary = await getGitAllBranches(cwd)
127
+ return {
128
+ action: 'branches',
129
+ ...summary.current !== undefined ? { current: summary.current } : {},
130
+ local: summary.local.map(b => ({
131
+ name: b.name,
132
+ isCurrent: b.isCurrent,
133
+ ...b.upstream !== undefined ? { upstream: b.upstream } : {},
134
+ ...b.ahead !== undefined ? { ahead: b.ahead } : {},
135
+ ...b.behind !== undefined ? { behind: b.behind } : {},
136
+ })),
137
+ remote: summary.remote.map(b => ({
138
+ name: b.name,
139
+ isCurrent: b.isCurrent,
140
+ })),
141
+ }
142
+ }
143
+ case 'tags': {
144
+ const rawTags = await getGitTags(cwd)
145
+ return {
146
+ action: 'tags',
147
+ total: rawTags.length,
148
+ tags: rawTags.map(t => ({
149
+ name: t.name,
150
+ commitHash: t.commitHash,
151
+ shortHash: t.shortHash,
152
+ date: t.date,
153
+ relativeDate: t.relativeDate,
154
+ message: t.message,
155
+ author: t.author,
156
+ isAnnotated: t.isAnnotated,
157
+ })),
158
+ }
159
+ }
160
+ case 'status':
161
+ default: {
162
+ const status = await getGitStatus(cwd)
163
+ return {
164
+ action: 'status',
165
+ isRepo: status.isRepo,
166
+ ...status.rootPath !== undefined ? { rootPath: status.rootPath } : {},
167
+ ...status.branch !== undefined ? { branch: status.branch } : {},
168
+ ...status.upstream !== undefined ? { upstream: status.upstream } : {},
169
+ ahead: status.ahead,
170
+ behind: status.behind,
171
+ staged: status.staged.map(f => ({ path: f.path, status: f.status, staged: f.staged })),
172
+ unstaged: status.unstaged.map(f => ({ path: f.path, status: f.status, staged: f.staged })),
173
+ untracked: status.untracked.map(f => ({ path: f.path, status: f.status, staged: f.staged })),
174
+ totalChanges: status.totalChanges,
175
+ }
176
+ }
177
+ }
178
+ },
179
+ presentCall: args => ({ card: 'generic', title: `git_view (${args.action ?? 'status'})`, rawInput: args }),
180
+ })), 'experimental-git: register git_view tool')
181
+
182
+ // 2. Dedicated tool for checking out or creating branches
183
+ scope.effect(() => scope.tools.register(defineTool({
184
+ name: 'git_checkout',
185
+ description: 'Switch branch or create and checkout a new branch in the current project Git repository.',
186
+ parameters: {
187
+ branch: {
188
+ type: 'string',
189
+ required: true,
190
+ description: 'The target branch name to switch to (e.g. "feature/auth", "dev", "master").',
191
+ },
192
+ create: {
193
+ type: 'boolean',
194
+ description: 'Whether to create a new branch from current HEAD and check it out (-b). Defaults to false.',
195
+ },
196
+ },
197
+ output: {
198
+ schema: {
199
+ type: 'object',
200
+ additionalProperties: true,
201
+ },
202
+ render: (_args, val) => {
203
+ const value = val as { success?: boolean; branch?: string; message?: string }
204
+ return [{
205
+ type: 'text',
206
+ text: value.success
207
+ ? `Successfully checked out branch '${value.branch ?? ''}'.`
208
+ : `Failed to checkout branch '${value.branch ?? ''}': ${value.message ?? ''}`,
209
+ }]
210
+ },
211
+ },
212
+
213
+ async execute(args, exec): Promise<Record<string, JsonValue>> {
214
+ const cwd = resolveDir(exec.agent?.session.id)
215
+ const branch = args.branch.trim()
216
+ const createNew = args.create ?? false
217
+ const result = await gitCheckout(cwd, branch, createNew)
218
+
219
+ return {
220
+ success: result.success,
221
+ branch,
222
+ message: result.message ?? '',
223
+ }
224
+ },
225
+ presentCall: args => ({
226
+ card: 'generic',
227
+ title: `git_checkout ${args.create ? '-b ' : ''}${args.branch}`,
228
+ rawInput: args,
229
+ }),
230
+ })), 'experimental-git: register git_checkout tool')
231
+
232
+ // 3. Dedicated tool for creating or deleting git tags
233
+ scope.effect(() => scope.tools.register(defineTool({
234
+ name: 'git_tag',
235
+ description: 'Create or delete a Git tag in the current project repository.',
236
+ parameters: {
237
+ name: {
238
+ type: 'string',
239
+ required: true,
240
+ description: 'The tag name (e.g. "v1.0.0", "release-20261020").',
241
+ },
242
+ action: {
243
+ type: 'string',
244
+ enum: ['create', 'delete'],
245
+ description: 'Tag operation: "create" (default) or "delete".',
246
+ },
247
+ commit: {
248
+ type: 'string',
249
+ description: 'Target commit hash to place the tag on when creating (defaults to current HEAD).',
250
+ },
251
+ message: {
252
+ type: 'string',
253
+ description: 'Optional annotation message for an annotated tag (git tag -a -m).',
254
+ },
255
+ },
256
+ output: {
257
+ schema: {
258
+ type: 'object',
259
+ additionalProperties: true,
260
+ },
261
+ render: (_args, val) => {
262
+ const value = val as { success?: boolean; name?: string; message?: string }
263
+ return [{
264
+ type: 'text',
265
+ text: value.message ?? (value.success ? 'Success' : 'Failed'),
266
+ }]
267
+ },
268
+ },
269
+ async execute(args, exec): Promise<Record<string, JsonValue>> {
270
+ const cwd = resolveDir(exec.agent?.session.id)
271
+ const name = args.name.trim()
272
+ const action = args.action ?? 'create'
273
+
274
+ if (action === 'delete') {
275
+ const result = await gitDeleteTag(cwd, name)
276
+ return {
277
+ success: result.success,
278
+ name,
279
+ action: 'delete',
280
+ message: result.message ?? '',
281
+ }
282
+ }
283
+
284
+ const result = await gitCreateTag(cwd, name, args.commit, args.message)
285
+ return {
286
+ success: result.success,
287
+ name,
288
+ action: 'create',
289
+ message: result.message ?? '',
290
+ }
291
+ },
292
+ presentCall: args => ({
293
+ card: 'generic',
294
+ title: `git_tag ${args.action ?? 'create'} ${args.name}`,
295
+ rawInput: args,
296
+ }),
297
+ })), 'experimental-git: register git_tag tool')
298
+ })
299
+ }