From 0553192ad6c1dd63bc135e9d8df8b782fa5d5b7b Mon Sep 17 00:00:00 2001 From: "chendaxin.tk" Date: Tue, 14 Apr 2026 16:02:38 +0800 Subject: [PATCH] feat: support local directory paths in /workspace init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `/workspace init` only accepted git URLs. Users with existing local directories had no way to bind them to a channel without cloning. Now `/workspace init ` accepts: - Absolute paths: `/workspace init /data/code/my-project` - Relative names: `/workspace init my-project` (resolved under base_dir) - Home-relative: `/workspace init ~/code/my-project` The conversational init flow also accepts directory paths — users can send a directory path when prompted instead of only git URLs. Adds looksLikeLocalDir() helper, MsgWsInitDirNotFound and MsgWsInitInvalidTarget i18n keys with translations for all 5 languages, and 4 new test cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- core/engine.go | 65 +++++++++++++++++++++++++--- core/engine_test.go | 82 ++++++++++++++++++++++++++++++++++++ core/i18n.go | 36 +++++++++++----- core/multi_workspace_test.go | 29 +++++++++++++ 4 files changed, 195 insertions(+), 17 deletions(-) diff --git a/core/engine.go b/core/engine.go index 42251e6..7159f8e 100644 --- a/core/engine.go +++ b/core/engine.go @@ -3401,13 +3401,29 @@ func (e *Engine) handleWorkspaceCommand(p Platform, msg *Message, args []string) e.reply(p, msg.ReplyCtx, e.i18n.Tf(successKey, wsName)) return true } - initWorkspace := func(bindingKey, repoURL string, successKey MsgKey) bool { - if !looksLikeGitURL(repoURL) { - e.reply(p, msg.ReplyCtx, "That doesn't look like a git URL.") + initWorkspace := func(bindingKey, target string, successKey MsgKey) bool { + // Support local directory paths (absolute or relative to baseDir). + if looksLikeLocalDir(target) { + dirPath := target + if !filepath.IsAbs(dirPath) { + dirPath = filepath.Join(e.baseDir, dirPath) + } + info, err := os.Stat(dirPath) + if err != nil || !info.IsDir() { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsInitDirNotFound, target)) + return false + } + e.workspaceBindings.Bind(bindingKey, channelKey, resolveChannelName(), normalizeWorkspacePath(dirPath)) + e.reply(p, msg.ReplyCtx, e.i18n.Tf(successKey, dirPath)) + return true + } + + if !looksLikeGitURL(target) { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsInitInvalidTarget)) return false } - repoName := extractRepoName(repoURL) + repoName := extractRepoName(target) cloneTo := filepath.Join(e.baseDir, repoName) if _, err := os.Stat(cloneTo); err == nil { @@ -3416,9 +3432,9 @@ func (e *Engine) handleWorkspaceCommand(p Platform, msg *Message, args []string) return true } - e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneProgress, repoURL)) + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneProgress, target)) - if err := gitClone(repoURL, cloneTo); err != nil { + if err := gitClone(target, cloneTo); err != nil { e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneFailed, err)) return false } @@ -10278,8 +10294,29 @@ func (e *Engine) handleWorkspaceInitFlow(p Platform, msg *Message, channelName s switch flow.state { case "awaiting_url": + // Accept local directory paths: bind directly without cloning. + if looksLikeLocalDir(content) { + dirPath := content + if !filepath.IsAbs(dirPath) { + dirPath = filepath.Join(e.baseDir, dirPath) + } + info, err := os.Stat(dirPath) + if err != nil || !info.IsDir() { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsInitDirNotFound, content)) + return true + } + projectKey := "project:" + e.name + e.workspaceBindings.Bind(projectKey, channelKey, flow.channelName, normalizeWorkspacePath(dirPath)) + e.initFlowsMu.Lock() + delete(e.initFlows, channelKey) + e.initFlowsMu.Unlock() + e.reply(p, msg.ReplyCtx, fmt.Sprintf( + "Bound workspace `%s` to this channel. Ready.", dirPath)) + return true + } + if !looksLikeGitURL(content) { - e.reply(p, msg.ReplyCtx, "That doesn't look like a git URL. Please provide a URL like `https://github.com/org/repo` or `git@github.com:org/repo.git`.") + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsInitInvalidTarget)) return true } repoName := extractRepoName(content) @@ -10337,6 +10374,20 @@ func looksLikeGitURL(s string) bool { strings.HasPrefix(s, "ssh://") } +// looksLikeLocalDir returns true if the string looks like a local directory +// path (absolute path, home-relative, dot-relative, or a bare name that +// doesn't look like a URL). +func looksLikeLocalDir(s string) bool { + if s == "" { + return false + } + return strings.HasPrefix(s, "/") || + strings.HasPrefix(s, "~/") || + strings.HasPrefix(s, "./") || + strings.HasPrefix(s, "../") || + (!strings.Contains(s, "://") && !strings.Contains(s, "@")) +} + func extractRepoName(url string) string { url = strings.TrimSuffix(url, ".git") // Handle git@host:org/repo format diff --git a/core/engine_test.go b/core/engine_test.go index a7d7af3..2435feb 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -7999,6 +7999,88 @@ func TestWorkspace_SharedInit_BindsExistingDir(t *testing.T) { } } +func TestWorkspace_Init_LocalDirAbsolute(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + + baseDir := t.TempDir() + wsDir := filepath.Join(baseDir, "my-project") + if err := os.MkdirAll(wsDir, 0o755); err != nil { + t.Fatal(err) + } + bindStore := filepath.Join(t.TempDir(), "bindings.json") + e.SetMultiWorkspace(baseDir, bindStore) + + msg := &Message{ + SessionKey: "test:ch1:user1", + Content: "/workspace init " + wsDir, + ReplyCtx: "ctx", + UserID: "user1", + } + e.handleCommand(p, msg, msg.Content) + + normalizedWsDir := normalizeWorkspacePath(wsDir) + projectKey := "project:test" + if got := e.workspaceBindings.Lookup(projectKey, workspaceChannelKey("test", "ch1")); got == nil || got.Workspace != normalizedWsDir { + t.Fatalf("expected init binding %q, got %+v", normalizedWsDir, got) + } +} + +func TestWorkspace_Init_LocalDirRelative(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + + baseDir := t.TempDir() + wsDir := filepath.Join(baseDir, "my-project") + if err := os.MkdirAll(wsDir, 0o755); err != nil { + t.Fatal(err) + } + bindStore := filepath.Join(t.TempDir(), "bindings.json") + e.SetMultiWorkspace(baseDir, bindStore) + + // Use relative name — should resolve under baseDir. + msg := &Message{ + SessionKey: "test:ch1:user1", + Content: "/workspace init my-project", + ReplyCtx: "ctx", + UserID: "user1", + } + e.handleCommand(p, msg, msg.Content) + + normalizedWsDir := normalizeWorkspacePath(wsDir) + projectKey := "project:test" + if got := e.workspaceBindings.Lookup(projectKey, workspaceChannelKey("test", "ch1")); got == nil || got.Workspace != normalizedWsDir { + t.Fatalf("expected init binding %q, got %+v", normalizedWsDir, got) + } +} + +func TestWorkspace_Init_LocalDirNotFound(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + + baseDir := t.TempDir() + bindStore := filepath.Join(t.TempDir(), "bindings.json") + e.SetMultiWorkspace(baseDir, bindStore) + + msg := &Message{ + SessionKey: "test:ch1:user1", + Content: "/workspace init nonexistent-dir", + ReplyCtx: "ctx", + UserID: "user1", + } + e.handleCommand(p, msg, msg.Content) + + sent := p.getSent() + if len(sent) == 0 || !strings.Contains(sent[0], "nonexistent-dir") { + t.Fatalf("expected error mentioning missing dir, got %v", sent) + } + + projectKey := "project:test" + if got := e.workspaceBindings.Lookup(projectKey, workspaceChannelKey("test", "ch1")); got != nil { + t.Fatalf("expected no binding for nonexistent dir, got %+v", got) + } +} + func TestWorkspace_Unbind_SharedBindingShowsHint(t *testing.T) { p := &stubPlatformEngine{n: "test"} e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) diff --git a/core/i18n.go b/core/i18n.go index 019a828..1b6c2a6 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -549,6 +549,8 @@ const ( MsgWsCloneProgress MsgKey = "ws_clone_progress" MsgWsCloneSuccess MsgKey = "ws_clone_success" MsgWsCloneFailed MsgKey = "ws_clone_failed" + MsgWsInitDirNotFound MsgKey = "ws_init_dir_not_found" + MsgWsInitInvalidTarget MsgKey = "ws_init_invalid_target" ) var messages = map[MsgKey]map[Language]string{ @@ -3518,11 +3520,11 @@ var messages = map[MsgKey]map[Language]string{ LangSpanish: "Uso: `/workspace [bind | route | init | unbind | list | shared ...]`", }, MsgWsInitUsage: { - LangEnglish: "Usage: `/workspace init `", - LangChinese: "用法: `/workspace init `", - LangTraditionalChinese: "用法: `/workspace init `", - LangJapanese: "使い方: `/workspace init `", - LangSpanish: "Uso: `/workspace init `", + LangEnglish: "Usage: `/workspace init `", + LangChinese: "用法: `/workspace init `", + LangTraditionalChinese: "用法: `/workspace init `", + LangJapanese: "使い方: `/workspace init `", + LangSpanish: "Uso: `/workspace init `", }, MsgWsBindUsage: { LangEnglish: "Usage: `/workspace bind `", @@ -3658,11 +3660,11 @@ var messages = map[MsgKey]map[Language]string{ LangSpanish: "El workspace efectivo actual proviene de la capa shared. Usa `/workspace shared unbind` para quitarlo.", }, MsgWsNotFoundHint: { - LangEnglish: "No workspace found for this channel. Send me a git repo URL to clone, or use `/workspace init `.", - LangChinese: "此频道未找到工作区。请发送 git 仓库地址进行克隆,或使用 `/workspace init <仓库地址>`。", - LangTraditionalChinese: "此頻道未找到工作區。請發送 git 倉庫地址進行克隆,或使用 `/workspace init <倉庫地址>`。", - LangJapanese: "このチャンネルにワークスペースが見つかりません。gitリポジトリURLを送信するか、`/workspace init ` を使用してください。", - LangSpanish: "No se encontró workspace para este canal. Envía una URL de repo git para clonar, o usa `/workspace init `.", + LangEnglish: "No workspace found for this channel. Send a git repo URL, a local directory path, or use `/workspace init `.", + LangChinese: "此频道未找到工作区。请发送 git 仓库地址或本地目录路径,或使用 `/workspace init <仓库地址或目录路径>`。", + LangTraditionalChinese: "此頻道未找到工作區。請發送 git 倉庫地址或本地目錄路徑,或使用 `/workspace init <倉庫地址或目錄路徑>`。", + LangJapanese: "このチャンネルにワークスペースが見つかりません。git URL またはローカルディレクトリパスを送信するか、`/workspace init ` を使用してください。", + LangSpanish: "No se encontró workspace para este canal. Envía una URL de repo git, una ruta de directorio local, o usa `/workspace init `.", }, MsgWsResolutionError: { LangEnglish: "Workspace resolution error: %v", @@ -3692,6 +3694,20 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "❌ リポジトリのクローンに失敗しました: %v", LangSpanish: "❌ Error al clonar repositorio: %v", }, + MsgWsInitDirNotFound: { + LangEnglish: "Directory not found: `%s`. Please provide a valid directory path or a git URL.", + LangChinese: "目录不存在: `%s`。请提供有效的目录路径或 git 仓库地址。", + LangTraditionalChinese: "目錄不存在: `%s`。請提供有效的目錄路徑或 git 倉庫地址。", + LangJapanese: "ディレクトリが見つかりません: `%s`。有効なディレクトリパスまたは git URL を指定してください。", + LangSpanish: "Directorio no encontrado: `%s`. Proporcione una ruta de directorio válida o una URL de git.", + }, + MsgWsInitInvalidTarget: { + LangEnglish: "Please provide a git URL (e.g. `https://github.com/org/repo`) or a local directory path.", + LangChinese: "请提供 git 仓库地址(如 `https://github.com/org/repo`)或本地目录路径。", + LangTraditionalChinese: "請提供 git 倉庫地址(如 `https://github.com/org/repo`)或本地目錄路徑。", + LangJapanese: "git URL(例: `https://github.com/org/repo`)またはローカルディレクトリパスを指定してください。", + LangSpanish: "Proporcione una URL de git (ej. `https://github.com/org/repo`) o una ruta de directorio local.", + }, } func (i *I18n) T(key MsgKey) string { diff --git a/core/multi_workspace_test.go b/core/multi_workspace_test.go index 3ef3886..736a9f0 100644 --- a/core/multi_workspace_test.go +++ b/core/multi_workspace_test.go @@ -396,6 +396,35 @@ func TestLooksLikeGitURL(t *testing.T) { } } +func TestLooksLikeLocalDir(t *testing.T) { + valid := []string{ + "/absolute/path", + "~/home/project", + "./relative", + "../parent", + "my-project", + "byted-sheet", + } + for _, s := range valid { + if !looksLikeLocalDir(s) { + t.Errorf("looksLikeLocalDir(%q) = false, want true", s) + } + } + + invalid := []string{ + "", + "https://github.com/org/repo", + "git@github.com:org/repo.git", + "ssh://git@github.com/org/repo", + "http://example.com", + } + for _, s := range invalid { + if looksLikeLocalDir(s) { + t.Errorf("looksLikeLocalDir(%q) = true, want false", s) + } + } +} + func TestWorkspaceInitFlow_SlashCommandCleansUpExistingFlow(t *testing.T) { baseDir := t.TempDir() e := newTestEngineWithMultiWorkspace(t, baseDir)