From b33ee31c743f1a8ba93d68e37cd27e48725a8e2f Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:35:57 +0800 Subject: [PATCH 1/2] feat(init): prompt for optional projects during interactive setup --- docs/designs/multi-project-management.md | 6 ++ docs/usage-guide.md | 9 +- docs/usage-guide.zh-CN.md | 7 +- src/__tests__/e2e/init-project-all.test.ts | 26 +++++ src/__tests__/init.test.ts | 88 ++++++++++++++++- src/init.ts | 108 +++++++++++++++++---- 6 files changed, 223 insertions(+), 21 deletions(-) diff --git a/docs/designs/multi-project-management.md b/docs/designs/multi-project-management.md index a3f15deee..0435df503 100644 --- a/docs/designs/multi-project-management.md +++ b/docs/designs/multi-project-management.md @@ -175,6 +175,12 @@ cd ~/work/hai-inference && teamai init --project hai-inference cd ~/work/billing && teamai init --project billing ``` +When `init` has no `--project` flag and the manifest declares projects, it offers +an optional multi-select after role selection. A blank answer and a non-interactive +run keep `projects: []`; neither auto-activates a project. The non-interactive +path prints the `teamai projects set ` follow-up, while an explicit flag +continues to resolve through the manifest as before. + There is deliberately **no `projects join/leave`** command. The tags analogy that suggested it does not hold: tags express a personal preference with no external basis and need an explicit toggle; a project has an external basis (cwd) and is diff --git a/docs/usage-guide.md b/docs/usage-guide.md index e93cd5245..85ad8a39c 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -209,7 +209,7 @@ Without a terminal `init` never waits on a person: every prompt takes its defaul | `--inherit-user-scope` | Project scope only: also sync safe user resources and search user knowledge | | `--no-inherit-user-scope` | Disable previously configured user-scope inheritance for this project | | `--role ` | Directly specify the primary role, skipping the interactive role prompt | -| `--project ` | Active logical project(s) from `manifest/projects.yaml` (comma-separated). Scopes which project resources and learnings this directory syncs. Pass `all` to activate every project the manifest declares. See [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role) below | +| `--project ` | Active logical project(s) from `manifest/projects.yaml` (comma-separated). Scopes which project resources and learnings this directory syncs. Pass `all` to activate every project the manifest declares. When omitted, an interactive `init` offers an optional project picker. See [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role) below | | `--force` | Overwrite existing config, skipping confirmation prompts | #### Multi-project: `project` as a dimension orthogonal to `role` @@ -231,6 +231,13 @@ cd ~/work/billing && teamai init --project billing Each directory then syncs only its own project's skills/rules/CLAUDE.md and learnings. Key points: +When `manifest/projects.yaml` declares projects and `--project` is omitted, +interactive `init` asks which projects belong to this directory after role +selection. Enter comma-separated numbers to choose several; press Enter to keep +the directory project-less. Without an interactive terminal, init keeps the +empty project set and prints `teamai projects set ` as the follow-up. An +explicit `--project` skips the picker. + - **Learnings isolation.** `learnings/` at the repo root is shared with the whole team; a project's private learnings live under `learnings//` and only surface in `teamai recall` for members of that project. A directory with diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 771d86d72..6733a34e0 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -197,7 +197,7 @@ GITHUB_TOKEN=ghp_... teamai init https://github.com/yourorg/yourrepo --scope pro | `--inherit-user-scope` | 仅 project scope:同时同步安全的 user 资源并检索 user 知识 | | `--no-inherit-user-scope` | 关闭当前项目先前配置的 user scope 继承 | | `--role ` | 直接指定 primaryRole,跳过角色交互选择 | -| `--project ` | 从 `manifest/projects.yaml` 激活的逻辑项目(逗号分隔)。决定本目录同步哪些项目的资源与 learnings。传 `all` 可激活 manifest 声明的全部项目。详见下方 [多项目](#多项目project-作为与-role-正交的维度) | +| `--project ` | 从 `manifest/projects.yaml` 激活的逻辑项目(逗号分隔)。决定本目录同步哪些项目的资源与 learnings。传 `all` 可激活 manifest 声明的全部项目;省略时,交互式 `init` 会显示可选项目。详见下方 [多项目](#多项目project-作为与-role-正交的维度) | | `--force` | 覆盖已有配置,跳过确认提示 | #### 多项目:`project` 作为与 `role` 正交的维度 @@ -216,6 +216,11 @@ cd ~/work/billing && teamai init --project billing 此后每个目录只同步自己项目的 skills/rules/CLAUDE.md 与 learnings。要点: +当 `manifest/projects.yaml` 声明了项目且未传 `--project` 时,交互式 +`init` 会在角色选择后询问本目录所属项目。输入逗号分隔的编号可选择多个; +直接回车则保持不属于任何项目。没有交互终端时会保留空项目集,并提示之后可运行 +`teamai projects set `。显式传入 `--project` 时跳过选择提示。 + - **learnings 隔离。** 仓库 `learnings/` 根目录对全团队共享;项目私有经验放在 `learnings//` 子目录下,只对该项目成员的 `teamai recall` 可见。 未激活任何项目的目录只能看到共享的根目录。 diff --git a/src/__tests__/e2e/init-project-all.test.ts b/src/__tests__/e2e/init-project-all.test.ts index 9b26d5490..24da1d4a6 100644 --- a/src/__tests__/e2e/init-project-all.test.ts +++ b/src/__tests__/e2e/init-project-all.test.ts @@ -354,4 +354,30 @@ describe("init --project all activates every project in the manifest (issue #509 expect(second.output).toMatch(/Failed to refresh existing clone|fast-forward|ff-only|not possible|diverg/i); } }, 90_000); + + it('skips the optional project picker without a terminal and keeps no project active', async () => { + const unattendedRoot = path.join(sandbox, 'unattended-project'); + const unattendedHome = path.join(sandbox, 'unattended-home'); + fs.mkdirSync(unattendedRoot, { recursive: true }); + fs.mkdirSync(unattendedHome, { recursive: true }); + fs.copyFileSync(path.join(home, '.gitconfig'), path.join(unattendedHome, '.gitconfig')); + + const result = await runCLI( + ['init', FAKE_URL, '--scope', 'project', '--role', 'common', '--force'], + unattendedRoot, + unattendedHome, + { ...cliEnv, TEAMAI_NONINTERACTIVE: '1' }, + ); + + expect(result.code, result.output).toBe(0); + expect(readProjects(unattendedRoot)).toEqual([]); + const manifestPath = path.join(readClonePath(unattendedRoot), 'manifest', 'projects.yaml'); + const projectIds = (YAML.parse(fs.readFileSync(manifestPath, 'utf8')) as { + projects: Array<{ id: string }>; + }).projects.map((project) => project.id); + expect(result.output).toContain( + `This team repo declares projects: ${projectIds.join(', ')}. Run ` + + '`teamai projects set ` to activate one.', + ); + }, 60_000); }); diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index af1a39680..bb7fe032d 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -134,6 +134,12 @@ vi.mock('../builtin-skills.js', () => ({ deployBuiltinSkills: (...args: unknown[]) => mockDeployBuiltinSkills(...args), })); +const mockLoadProjectsManifest = vi.fn().mockResolvedValue(null); +vi.mock('../projects.js', async (importOriginal) => ({ + ...(await importOriginal()), + loadProjectsManifest: (...args: unknown[]) => mockLoadProjectsManifest(...args), +})); + vi.mock('../roles.js', () => ({ loadRolesManifest: vi.fn().mockResolvedValue({ version: 1, @@ -210,7 +216,8 @@ vi.mock('../types.js', async (importOriginal) => { // Mock prompt to auto-answer prompts let questionAnswers: string[] = []; -vi.mock('../utils/prompt.js', () => ({ +vi.mock('../utils/prompt.js', async (importOriginal) => ({ + ...(await importOriginal()), // Mirror the real predicate's TTY leg so tests that force `isTTY` keep // driving the interactive branch, independent of CI=true on the runner. isInteractive: () => Boolean(process.stdin.isTTY), @@ -628,6 +635,85 @@ describe('init', () => { }); }); + describe('interactive project selection', () => { + it('accepts multiple project numbers and persists them on init', async () => { + let cloneDone = false; + pathExistsFn = (p: string) => (p === localPath ? cloneDone : false); + mockGfRepoClone.mockImplementation(() => { cloneDone = true; }); + vi.mocked(await import('../config.js')).loadTeamConfig.mockResolvedValue({ + team: 'my-team', + repo: 'https://git.woa.com/HyperAI/teamai-test.git', + provider: 'tgit', + reviewers: [], + sharing: { rules: { enforced: [] }, docs: {}, env: { injectShellProfile: true } }, + toolPaths: {}, + } as never); + mockLoadProjectsManifest.mockResolvedValueOnce({ + version: 1, + projects: [ + { id: 'inference', name: 'Inference', description: '', resources: {} }, + { id: 'billing', name: 'Billing', description: '', resources: {} }, + ], + }); + questionAnswers = ['1', '2,1']; + + await init({ + repo: 'https://git.woa.com/HyperAI/teamai-test.git', + scope: 'user', + force: true, + dryRun: true, + }); + + expect(mockLoadProjectsManifest).toHaveBeenCalledTimes(1); + const { askQuestion } = await import('../utils/prompt.js'); + expect(askQuestion).toHaveBeenCalledWith( + 'Project(s) for this directory (comma-separated numbers; press Enter for none): ', + '', + ); + expect(questionAnswers).toHaveLength(0); + expect(saveLocalConfig).toHaveBeenCalledWith(expect.objectContaining({ + primaryRole: 'hai', + projects: ['inference', 'billing'], + })); + }); + + it('keeps an empty project set when the prompt is skipped and prints the follow-up command', async () => { + let cloneDone = false; + pathExistsFn = (p: string) => (p === localPath ? cloneDone : false); + mockGfRepoClone.mockImplementation(() => { cloneDone = true; }); + vi.mocked(await import('../config.js')).loadTeamConfig.mockResolvedValue({ + team: 'my-team', + repo: 'https://git.woa.com/HyperAI/teamai-test.git', + provider: 'tgit', + reviewers: [], + sharing: { rules: { enforced: [] }, docs: {}, env: { injectShellProfile: true } }, + toolPaths: {}, + } as never); + mockLoadProjectsManifest.mockResolvedValueOnce({ + version: 1, + projects: [ + { id: 'inference', name: 'Inference', description: '', resources: {} }, + { id: 'billing', name: 'Billing', description: '', resources: {} }, + ], + }); + questionAnswers = []; + + await init({ + repo: 'https://git.woa.com/HyperAI/teamai-test.git', + scope: 'user', + role: 'hai', + force: true, + dryRun: true, + }); + + expect(saveLocalConfig).toHaveBeenCalledWith(expect.objectContaining({ projects: [] })); + const { log } = await import('../utils/logger.js'); + expect(log.info).toHaveBeenCalledWith( + 'This team repo declares projects: inference, billing. Run `teamai projects set ` to activate one.', + ); + }); + }); + /** Init against a clone whose teamai.yaml loads, so the stub deploy runs. */ async function initWithTeamConfig(): Promise { let cloneDone = false; diff --git a/src/init.ts b/src/init.ts index 174d151fa..9f813d44c 100644 --- a/src/init.ts +++ b/src/init.ts @@ -92,9 +92,9 @@ import { } from './types.js'; import { getUserHome } from './utils/home.js'; import { describeRoles, listRoleIds, loadRolesManifest, RolesManifestNotFoundError } from './roles.js'; -import { loadProjectsManifest, listProjectIds } from './projects.js'; +import { loadProjectsManifest, listProjectIds, type ProjectsManifest } from './projects.js'; import { memberReadRoots, readMemberConfig, mergeMemberConfig } from './members.js'; -import { askQuestion, askConfirmation, askSelection, closePrompt, isInteractive } from './utils/prompt.js'; +import { askQuestion, askConfirmation, askSelection, closePrompt, isInteractive, parseSelection } from './utils/prompt.js'; import { normalizeAgentList, detectHomeInstalledAgents, @@ -366,6 +366,49 @@ export async function resolveActiveProjects( return { projects: dedupeIds(requested) }; } +/** + * Resolve `init` project membership. An explicit flag always wins; otherwise an + * interactive init offers the manifest's projects, while a blank answer or a + * non-interactive run keeps the existing no-project default. + */ +async function resolveProjectsForInit( + repoPath: string, + projectFlag?: string, + manifestOverride?: ProjectsManifest | null, +): Promise> { + if (projectFlag !== undefined) return resolveActiveProjects(repoPath, projectFlag); + + const manifest = manifestOverride !== undefined ? manifestOverride : await loadProjectsManifest(repoPath); + if (!manifest || manifest.projects.length === 0) return { projects: [] }; + + log.info('Available projects:'); + manifest.projects.forEach((project, index) => { + const name = project.name ? ` (${project.name})` : ''; + log.info(` ${index + 1}. ${project.id}${name}`); + }); + + const answer = await askQuestion( + 'Project(s) for this directory (comma-separated numbers; press Enter for none): ', + '', + ); + if (!answer.trim()) { + log.info( + `This team repo declares projects: ${listProjectIds(manifest).join(', ')}. ` + + 'Run `teamai projects set ` to activate one.', + ); + return { projects: [] }; + } + + const selected = parseSelection(answer, manifest.projects.length); + if (!selected) { + throw new Error( + `Invalid project selection. Choose numbers between 1 and ${manifest.projects.length}, separated by commas, or press Enter for none.`, + ); + } + + return { projects: selected.map((index) => manifest.projects[index].id) }; +} + /** * Resolve init install scope from `--scope` / default. * @@ -650,7 +693,7 @@ export async function initHttp( const lenient = error instanceof RolesManifestNotFoundError || error instanceof NoRoleSelectedError; if (!lenient) throw error; } - Object.assign(localConfig, await resolveActiveProjects(localPath, options.project)); + Object.assign(localConfig, await resolveProjectsForInit(localPath, options.project)); // Persist --agent into enabledAgents (additive across runs) const requestedAgents = normalizeAgentList(options.agent); @@ -1173,7 +1216,7 @@ export async function initSelfRepo(options: GlobalOptions & { const lenient = error instanceof RolesManifestNotFoundError || error instanceof NoRoleSelectedError; if (!lenient) throw error; } - Object.assign(localConfig, await resolveActiveProjects(localPath, options.project)); + Object.assign(localConfig, await resolveProjectsForInit(localPath, options.project)); // Which AI tools to set up in this repo (create skills dir + inject hooks + // commit their settings.json). Resolved from --agent, else HOME detection // (non-interactive), else an interactive picker. Written to enabledAgents, @@ -1749,12 +1792,38 @@ export async function init(options: GlobalOptions & { } } - // Resolve active projects (non-interactive: --project flag only) so the roster - // records project membership. Role selection stays in its original place below - // (it may prompt) — the member file's project membership is the P3 goal here. + // Resolve projects before member registration so the reports branch records + // the same selection as the local config. Only prompt for a role early when a + // project picker will follow it; otherwise keep the existing prompt order. + let roleProfile: Awaited> | undefined; + let rolePromptedEarly = false; + let projectsManifest: ProjectsManifest | null | undefined; + if (options.project === undefined) { + try { + projectsManifest = await loadProjectsManifest(localPath); + } catch (error) { + log.error((error as Error).message); + process.exit(1); + } + } + if (projectsManifest?.projects.length) { + rolePromptedEarly = true; + try { + roleProfile = await promptForRoleProfile(localPath, options.role); + } catch (error) { + const msg = (error as Error).message; + if (msg.includes('Roles manifest not found')) { + log.debug('No roles manifest found — skipping role selection'); + } else { + log.error(msg); + process.exit(1); + } + } + } + let resolvedProjects: string[] = []; try { - resolvedProjects = (await resolveActiveProjects(localPath, options.project)).projects ?? []; + resolvedProjects = (await resolveProjectsForInit(localPath, options.project, projectsManifest)).projects ?? []; } catch (error) { // A bad --project is a user error on the main init path: fail loudly. log.error((error as Error).message); @@ -1889,19 +1958,22 @@ export async function init(options: GlobalOptions & { ...(inheritUserScope !== undefined ? { inheritUserScope } : {}), }; - try { - Object.assign(localConfig, await promptForRoleProfile(localPath, options.role)); - } catch (error) { - const msg = (error as Error).message; - if (msg.includes('Roles manifest not found')) { - log.debug('No roles manifest found — skipping role selection'); - } else { - log.error(msg); - process.exit(1); + if (!rolePromptedEarly) { + try { + roleProfile = await promptForRoleProfile(localPath, options.role); + } catch (error) { + const msg = (error as Error).message; + if (msg.includes('Roles manifest not found')) { + log.debug('No roles manifest found — skipping role selection'); + } else { + log.error(msg); + process.exit(1); + } } } + if (roleProfile) Object.assign(localConfig, roleProfile); - // Projects were already resolved (non-interactively) before member registration. + // Projects were resolved before member registration so both records agree. localConfig.projects = resolvedProjects; // Persist --agent into enabledAgents (additive across runs) From bf72e64616b9dae044fc711961f48c80b0841ef9 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:18:11 +0800 Subject: [PATCH 2/2] docs(setup): ask before selecting init projects --- skill-data/setup/references/join-member.md | 9 +++++++++ skill-data/setup/references/setup-admin.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index ee01c186b..ca52f1007 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -69,6 +69,15 @@ to add them to the repo. ## Step 4 — Initialize with the URL (you run it) +Before running `init`, ask whether the user wants to activate any logical +projects this team repo declares. If `init` lists **Available projects**, show +the names/IDs and ask which belong to this setup; enter the corresponding +comma-separated numbers. Press Enter for none only when the user explicitly +chooses no project. If the IDs are already known, pass `--project id1,id2` to +skip the picker. For a non-interactive run, ask first and pass `--project`: +without it, init keeps `projects: []` and prints a `teamai projects set ` +follow-up instead of waiting for a choice. + ```bash # this project only (run from inside the project) teamai init https://// diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index a932c5191..6194c71b9 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -165,6 +165,15 @@ suggested form `TeamAi-`.) Use the **full URL**, never `owner/repo`: +Ask whether the user wants to activate any logical projects this team repo +declares. If `init` lists **Available projects**, show the names/IDs and ask +which belong to this setup; enter the corresponding comma-separated numbers. +Press Enter for none only when the user explicitly chooses no project. If the +IDs are already known, pass `--project id1,id2` to skip the picker. For a +non-interactive run, ask first and pass `--project`: without it, init keeps +`projects: []` and prints a `teamai projects set ` follow-up instead of +waiting for a choice. + ```bash # project scope (default) — run from inside the project directory teamai init https:////