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

Commit48a1956

Browse files
committed
Add coder_workspace_edit_file MCP tool
1 parent6d39077 commit48a1956

File tree

9 files changed

+438
-2
lines changed

9 files changed

+438
-2
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
"syscall"
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
typeHTTPResponseCode=int
@@ -165,3 +169,91 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
165169

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

‎agent/files_test.go‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import (
1313

1414
"github.com/spf13/afero"
1515
"github.com/stretchr/testify/require"
16+
"golang.org/x/xerrors"
1617

1718
"github.com/coder/coder/v2/agent"
1819
"github.com/coder/coder/v2/agent/agenttest"
1920
"github.com/coder/coder/v2/coderd/coderdtest"
2021
"github.com/coder/coder/v2/codersdk/agentsdk"
22+
"github.com/coder/coder/v2/codersdk/workspacesdk"
2123
"github.com/coder/coder/v2/testutil"
2224
)
2325

@@ -91,6 +93,13 @@ func (fs *testFs) MkdirAll(name string, mode os.FileMode) error {
9193
returnfs.Fs.MkdirAll(name,mode)
9294
}
9395

96+
func (fs*testFs)Rename(oldName,newNamestring)error {
97+
iferr:=fs.intercept("rename",newName);err!=nil {
98+
returnerr
99+
}
100+
returnfs.Fs.Rename(oldName,newName)
101+
}
102+
94103
funcTestReadFile(t*testing.T) {
95104
t.Parallel()
96105

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

‎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