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

Commit9435706

Browse files
committed
Add coder_workspace_edit_file MCP tool
1 parent911efb7 commit9435706

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
@@ -15,6 +15,7 @@ import (
1515
"github.com/coder/coder/v2/agent/agenttest"
1616
"github.com/coder/coder/v2/coderd/coderdtest"
1717
"github.com/coder/coder/v2/codersdk/agentsdk"
18+
"github.com/coder/coder/v2/codersdk/workspacesdk"
1819
"github.com/coder/coder/v2/testutil"
1920
)
2021

@@ -72,6 +73,13 @@ func (fs *testFs) MkdirAll(name string, mode os.FileMode) error {
7273
returnfs.Fs.MkdirAll(name,mode)
7374
}
7475

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

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

‎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 {
@@ -1472,6 +1474,69 @@ var WorkspaceWriteFile = Tool[WorkspaceWriteFileArgs, codersdk.Response]{
14721474
},
14731475
}
14741476

1477+
typeWorkspaceEditFileArgsstruct {
1478+
Workspacestring`json:"workspace"`
1479+
Pathstring`json:"path"`
1480+
Edits []workspacesdk.FileEdit`json:"edits"`
1481+
}
1482+
1483+
varWorkspaceEditFile=Tool[WorkspaceEditFileArgs, codersdk.Response]{
1484+
Tool: aisdk.Tool{
1485+
Name:ToolNameWorkspaceEditFile,
1486+
Description:`Edit a file in a workspace.`,
1487+
Schema: aisdk.Schema{
1488+
Properties:map[string]any{
1489+
"workspace":map[string]any{
1490+
"type":"string",
1491+
"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.",
1492+
},
1493+
"path":map[string]any{
1494+
"type":"string",
1495+
"description":"The absolute path of the file to write in the workspace.",
1496+
},
1497+
"edits":map[string]any{
1498+
"type":"array",
1499+
"description":"An array of edit operations.",
1500+
"items": []any{
1501+
map[string]any{
1502+
"type":"object",
1503+
"properties":map[string]any{
1504+
"search":map[string]any{
1505+
"type":"string",
1506+
"description":"The old string to replace.",
1507+
},
1508+
"replace":map[string]any{
1509+
"type":"string",
1510+
"description":"The new string that replaces the old string.",
1511+
},
1512+
},
1513+
"required": []string{"search","replace"},
1514+
},
1515+
},
1516+
},
1517+
},
1518+
Required: []string{"path","workspace","edits"},
1519+
},
1520+
},
1521+
UserClientOptional:true,
1522+
Handler:func(ctx context.Context,depsDeps,argsWorkspaceEditFileArgs) (codersdk.Response,error) {
1523+
conn,err:=newAgentConn(ctx,deps.coderClient,args.Workspace)
1524+
iferr!=nil {
1525+
return codersdk.Response{},err
1526+
}
1527+
deferconn.Close()
1528+
1529+
err=conn.EditFile(ctx,args.Path, workspacesdk.FileEditRequest{Edits:args.Edits})
1530+
iferr!=nil {
1531+
return codersdk.Response{},err
1532+
}
1533+
1534+
return codersdk.Response{
1535+
Message:"File edited successfully.",
1536+
},nil
1537+
},
1538+
}
1539+
14751540
// NormalizeWorkspaceInput converts workspace name input to standard format.
14761541
// Handles the following input formats:
14771542
// - workspace → workspace

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp