Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitd82a0ff

Browse files
committed
Add coder_workspace_edit_file MCP tool
1 parentd231994 commitd82a0ff

File tree

9 files changed

+414
-0
lines changed

9 files changed

+414
-0
lines changed

‎agent/api.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ func (a *agent) apiHandler() http.Handler {
6262
r.Post("/api/v0/list-directory",a.HandleLS)
6363
r.Get("/api/v0/read-file",a.HandleReadFile)
6464
r.Post("/api/v0/write-file",a.HandleWriteFile)
65+
r.Post("/api/v0/edit-file",a.HandleEditFile)
6566
r.Get("/debug/logs",a.HandleHTTPDebugLogs)
6667
r.Get("/debug/magicsock",a.HandleHTTPDebugMagicsock)
6768
r.Get("/debug/magicsock/debug-logging/{state}",a.HandleHTTPMagicsockDebugLoggingState)

‎agent/files.go‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ import (
1212
"strconv"
1313
"strings"
1414

15+
"github.com/icholy/replace"
16+
"github.com/spf13/afero"
17+
"golang.org/x/text/transform"
1518
"golang.org/x/xerrors"
1619

1720
"cdr.dev/slog"
1821
"github.com/coder/coder/v2/coderd/httpapi"
1922
"github.com/coder/coder/v2/codersdk"
23+
"github.com/coder/coder/v2/codersdk/workspacesdk"
2024
)
2125

2226
func (a*agent)HandleReadFile(rw http.ResponseWriter,r*http.Request) {
@@ -164,3 +168,91 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (in
164168

165169
return0,nil
166170
}
171+
172+
func (a*agent)HandleEditFile(rw http.ResponseWriter,r*http.Request) {
173+
ctx:=r.Context()
174+
175+
query:=r.URL.Query()
176+
parser:=httpapi.NewQueryParamParser().RequiredNotEmpty("path")
177+
path:=parser.String(query,"","path")
178+
parser.ErrorExcessParams(query)
179+
iflen(parser.Errors)>0 {
180+
httpapi.Write(ctx,rw,http.StatusBadRequest, codersdk.Response{
181+
Message:"Query parameters have invalid values.",
182+
Validations:parser.Errors,
183+
})
184+
return
185+
}
186+
187+
varedits workspacesdk.FileEditRequest
188+
if!httpapi.Read(ctx,rw,r,&edits) {
189+
return
190+
}
191+
192+
status,err:=a.editFile(path,edits.Edits)
193+
iferr!=nil {
194+
httpapi.Write(ctx,rw,status, codersdk.Response{
195+
Message:err.Error(),
196+
})
197+
return
198+
}
199+
200+
httpapi.Write(ctx,rw,http.StatusOK, codersdk.Response{
201+
Message:fmt.Sprintf("Successfully edited %q",path),
202+
})
203+
}
204+
205+
func (a*agent)editFile(pathstring,edits []workspacesdk.FileEdit) (int,error) {
206+
if!filepath.IsAbs(path) {
207+
returnhttp.StatusBadRequest,xerrors.Errorf("file path must be absolute: %q",path)
208+
}
209+
210+
f,err:=a.filesystem.Open(path)
211+
iferr!=nil {
212+
status:=http.StatusInternalServerError
213+
switch {
214+
caseerrors.Is(err,os.ErrNotExist):
215+
status=http.StatusNotFound
216+
caseerrors.Is(err,os.ErrPermission):
217+
status=http.StatusForbidden
218+
}
219+
returnstatus,err
220+
}
221+
deferf.Close()
222+
223+
stat,err:=f.Stat()
224+
iferr!=nil {
225+
returnhttp.StatusInternalServerError,err
226+
}
227+
228+
ifstat.IsDir() {
229+
returnhttp.StatusBadRequest,xerrors.Errorf("open %s: not a file",path)
230+
}
231+
232+
iflen(edits)==0 {
233+
returnhttp.StatusBadRequest,xerrors.New("must specify at least one edit")
234+
}
235+
236+
transforms:=make([]transform.Transformer,len(edits))
237+
fori,edit:=rangeedits {
238+
transforms[i]=replace.String(edit.Search,edit.Replace)
239+
}
240+
241+
tmpfile,err:=afero.TempFile(a.filesystem,"",filepath.Base(path))
242+
iferr!=nil {
243+
returnhttp.StatusInternalServerError,err
244+
}
245+
defertmpfile.Close()
246+
247+
_,err=io.Copy(tmpfile,replace.Chain(f,transforms...))
248+
iferr!=nil {
249+
returnhttp.StatusInternalServerError,xerrors.Errorf("edit %s: %w",path,err)
250+
}
251+
252+
err=a.filesystem.Rename(tmpfile.Name(),path)
253+
iferr!=nil {
254+
returnhttp.StatusInternalServerError,err
255+
}
256+
257+
return0,nil
258+
}

‎agent/files_test.go‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/coder/coder/v2/agent/agenttest"
1818
"github.com/coder/coder/v2/coderd/coderdtest"
1919
"github.com/coder/coder/v2/codersdk/agentsdk"
20+
"github.com/coder/coder/v2/codersdk/workspacesdk"
2021
"github.com/coder/coder/v2/testutil"
2122
)
2223

@@ -74,6 +75,13 @@ func (fs *testFs) MkdirAll(name string, mode os.FileMode) error {
7475
returnfs.Fs.MkdirAll(name,mode)
7576
}
7677

78+
func (fs*testFs)Rename(oldName,newNamestring)error {
79+
iferr:=fs.intercept("rename",newName);err!=nil {
80+
returnerr
81+
}
82+
returnfs.Fs.Rename(oldName,newName)
83+
}
84+
7785
funcTestReadFile(t*testing.T) {
7886
t.Parallel()
7987

@@ -354,3 +362,159 @@ func TestWriteFile(t *testing.T) {
354362
})
355363
}
356364
}
365+
366+
funcTestEditFile(t*testing.T) {
367+
t.Parallel()
368+
369+
tmpdir:=os.TempDir()
370+
noPermsFilePath:=filepath.Join(tmpdir,"no-perms-file")
371+
failRenameFilePath:=filepath.Join(tmpdir,"fail-rename")
372+
//nolint:dogsled
373+
conn,_,_,fs,_:=setupAgent(t, agentsdk.Manifest{},0,func(_*agenttest.Client,opts*agent.Options) {
374+
opts.Filesystem=newTestFs(opts.Filesystem,func(call,filestring)error {
375+
iffile==noPermsFilePath {
376+
returnos.ErrPermission
377+
}elseiffile==failRenameFilePath&&call=="rename" {
378+
returnxerrors.New("rename failed")
379+
}
380+
returnnil
381+
})
382+
})
383+
384+
dirPath:=filepath.Join(tmpdir,"directory")
385+
err:=fs.MkdirAll(dirPath,0o755)
386+
require.NoError(t,err)
387+
388+
tests:= []struct {
389+
namestring
390+
pathstring
391+
contentsstring
392+
edits []workspacesdk.FileEdit
393+
expectedstring
394+
errCodeint
395+
errorstring
396+
}{
397+
{
398+
name:"NoPath",
399+
errCode:http.StatusBadRequest,
400+
error:"\"path\" is required",
401+
},
402+
{
403+
name:"RelativePath",
404+
path:"./relative",
405+
errCode:http.StatusBadRequest,
406+
error:"file path must be absolute",
407+
},
408+
{
409+
name:"RelativePath",
410+
path:"also-relative",
411+
errCode:http.StatusBadRequest,
412+
error:"file path must be absolute",
413+
},
414+
{
415+
name:"NonExistent",
416+
path:filepath.Join(tmpdir,"does-not-exist"),
417+
errCode:http.StatusNotFound,
418+
error:"file does not exist",
419+
},
420+
{
421+
name:"IsDir",
422+
path:dirPath,
423+
errCode:http.StatusBadRequest,
424+
error:"not a file",
425+
},
426+
{
427+
name:"NoPermissions",
428+
path:noPermsFilePath,
429+
errCode:http.StatusForbidden,
430+
error:"permission denied",
431+
},
432+
{
433+
name:"NoEdits",
434+
path:filepath.Join(tmpdir,"no-edits"),
435+
contents:"foo bar",
436+
errCode:http.StatusBadRequest,
437+
error:"must specify at least one edit",
438+
},
439+
{
440+
name:"FailRename",
441+
path:failRenameFilePath,
442+
contents:"foo bar",
443+
edits: []workspacesdk.FileEdit{
444+
{
445+
Search:"foo",
446+
Replace:"bar",
447+
},
448+
},
449+
errCode:http.StatusInternalServerError,
450+
error:"rename failed",
451+
},
452+
{
453+
name:"Edit1",
454+
path:filepath.Join(tmpdir,"edit1"),
455+
contents:"foo bar",
456+
edits: []workspacesdk.FileEdit{
457+
{
458+
Search:"foo",
459+
Replace:"bar",
460+
},
461+
},
462+
expected:"bar bar",
463+
},
464+
{
465+
name:"EditEdit",// Edits affect previous edits.
466+
path:filepath.Join(tmpdir,"edit-edit"),
467+
contents:"foo bar",
468+
edits: []workspacesdk.FileEdit{
469+
{
470+
Search:"foo",
471+
Replace:"bar",
472+
},
473+
{
474+
Search:"bar",
475+
Replace:"qux",
476+
},
477+
},
478+
expected:"qux qux",
479+
},
480+
{
481+
name:"Multiline",
482+
path:filepath.Join(tmpdir,"multiline"),
483+
contents:"foo\nbar\nbaz\nqux",
484+
edits: []workspacesdk.FileEdit{
485+
{
486+
Search:"bar\nbaz",
487+
Replace:"frob",
488+
},
489+
},
490+
expected:"foo\nfrob\nqux",
491+
},
492+
}
493+
494+
for_,tt:=rangetests {
495+
t.Run(tt.name,func(t*testing.T) {
496+
t.Parallel()
497+
498+
ctx,cancel:=context.WithTimeout(context.Background(),testutil.WaitLong)
499+
defercancel()
500+
501+
iftt.contents!="" {
502+
err:=afero.WriteFile(fs,tt.path, []byte(tt.contents),0o644)
503+
require.NoError(t,err)
504+
}
505+
506+
err:=conn.EditFile(ctx,tt.path, workspacesdk.FileEditRequest{Edits:tt.edits})
507+
iftt.errCode!=0 {
508+
require.Error(t,err)
509+
cerr:=coderdtest.SDKError(t,err)
510+
require.Contains(t,cerr.Error(),tt.error)
511+
require.Equal(t,tt.errCode,cerr.StatusCode())
512+
}else {
513+
require.NoError(t,err)
514+
b,err:=afero.ReadFile(fs,tt.path)
515+
require.NoError(t,err)
516+
require.Equal(t,tt.expected,string(b))
517+
}
518+
})
519+
}
520+
}

‎codersdk/toolsdk/toolsdk.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const (
4444
ToolNameChatGPTFetch="fetch"
4545
ToolNameWorkspaceReadFile="coder_workspace_read_file"
4646
ToolNameWorkspaceWriteFile="coder_workspace_write_file"
47+
ToolNameWorkspaceEditFile="coder_workspace_edit_file"
4748
)
4849

4950
funcNewDeps(client*codersdk.Client,opts...func(*Deps)) (Deps,error) {
@@ -213,6 +214,7 @@ var All = []GenericTool{
213214
ChatGPTFetch.Generic(),
214215
WorkspaceReadFile.Generic(),
215216
WorkspaceWriteFile.Generic(),
217+
WorkspaceEditFile.Generic(),
216218
}
217219

218220
typeReportTaskArgsstruct {
@@ -1491,6 +1493,69 @@ var WorkspaceWriteFile = Tool[WorkspaceWriteFileArgs, codersdk.Response]{
14911493
},
14921494
}
14931495

1496+
typeWorkspaceEditFileArgsstruct {
1497+
Workspacestring`json:"workspace"`
1498+
Pathstring`json:"path"`
1499+
Edits []workspacesdk.FileEdit`json:"edits"`
1500+
}
1501+
1502+
varWorkspaceEditFile=Tool[WorkspaceEditFileArgs, codersdk.Response]{
1503+
Tool: aisdk.Tool{
1504+
Name:ToolNameWorkspaceEditFile,
1505+
Description:`Edit a file in a workspace.`,
1506+
Schema: aisdk.Schema{
1507+
Properties:map[string]any{
1508+
"workspace":map[string]any{
1509+
"type":"string",
1510+
"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.",
1511+
},
1512+
"path":map[string]any{
1513+
"type":"string",
1514+
"description":"The absolute path of the file to write in the workspace.",
1515+
},
1516+
"edits":map[string]any{
1517+
"type":"array",
1518+
"description":"An array of edit operations.",
1519+
"items": []any{
1520+
map[string]any{
1521+
"type":"object",
1522+
"properties":map[string]any{
1523+
"search":map[string]any{
1524+
"type":"string",
1525+
"description":"The old string to replace.",
1526+
},
1527+
"replace":map[string]any{
1528+
"type":"string",
1529+
"description":"The new string that replaces the old string.",
1530+
},
1531+
},
1532+
"required": []string{"search","replace"},
1533+
},
1534+
},
1535+
},
1536+
},
1537+
Required: []string{"path","workspace","edits"},
1538+
},
1539+
},
1540+
UserClientOptional:true,
1541+
Handler:func(ctx context.Context,depsDeps,argsWorkspaceEditFileArgs) (codersdk.Response,error) {
1542+
conn,err:=newAgentConn(ctx,deps.coderClient,args.Workspace)
1543+
iferr!=nil {
1544+
return codersdk.Response{},err
1545+
}
1546+
deferconn.Close()
1547+
1548+
err=conn.EditFile(ctx,args.Path, workspacesdk.FileEditRequest{Edits:args.Edits})
1549+
iferr!=nil {
1550+
return codersdk.Response{},err
1551+
}
1552+
1553+
return codersdk.Response{
1554+
Message:"File edited successfully.",
1555+
},nil
1556+
},
1557+
}
1558+
14941559
// NormalizeWorkspaceInput converts workspace name input to standard format.
14951560
// Handles the following input formats:
14961561
// - workspace → workspace

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp