Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/designs/multi-project-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ cd ~/work/hai-inference && teamai init <team-repo> --project hai-inference
cd ~/work/billing && teamai init <team-repo> --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 <id>` 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
Expand Down
9 changes: 8 additions & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` | Directly specify the primary role, skipping the interactive role prompt |
| `--project <ids>` | 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 <ids>` | 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`
Expand All @@ -231,6 +231,13 @@ cd ~/work/billing && teamai init <team-repo> --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 <id>` 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/<project-id>/` and
only surface in `teamai recall` for members of that project. A directory with
Expand Down
7 changes: 6 additions & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` | 直接指定 primaryRole,跳过角色交互选择 |
| `--project <ids>` | 从 `manifest/projects.yaml` 激活的逻辑项目(逗号分隔)。决定本目录同步哪些项目的资源与 learnings。传 `all` 可激活 manifest 声明的全部项目。详见下方 [多项目](#多项目project-作为与-role-正交的维度) |
| `--project <ids>` | 从 `manifest/projects.yaml` 激活的逻辑项目(逗号分隔)。决定本目录同步哪些项目的资源与 learnings。传 `all` 可激活 manifest 声明的全部项目;省略时,交互式 `init` 会显示可选项目。详见下方 [多项目](#多项目project-作为与-role-正交的维度) |
| `--force` | 覆盖已有配置,跳过确认提示 |

#### 多项目:`project` 作为与 `role` 正交的维度
Expand All @@ -216,6 +216,11 @@ cd ~/work/billing && teamai init <team-repo> --project billing

此后每个目录只同步自己项目的 skills/rules/CLAUDE.md 与 learnings。要点:

当 `manifest/projects.yaml` 声明了项目且未传 `--project` 时,交互式
`init` 会在角色选择后询问本目录所属项目。输入逗号分隔的编号可选择多个;
直接回车则保持不属于任何项目。没有交互终端时会保留空项目集,并提示之后可运行
`teamai projects set <id>`。显式传入 `--project` 时跳过选择提示。

- **learnings 隔离。** 仓库 `learnings/` 根目录对全团队共享;项目私有经验放在
`learnings/<project-id>/` 子目录下,只对该项目成员的 `teamai recall` 可见。
未激活任何项目的目录只能看到共享的根目录。
Expand Down
9 changes: 9 additions & 0 deletions skill-data/setup/references/join-member.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`
follow-up instead of waiting for a choice.

```bash
# this project only (run from inside the project)
teamai init https://<platform>/<org>/<repo>
Expand Down
9 changes: 9 additions & 0 deletions skill-data/setup/references/setup-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ suggested form `TeamAi-<team-name>`.)

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 <id>` follow-up instead of
waiting for a choice.

```bash
# project scope (default) — run from inside the project directory
teamai init https://<platform>/<org>/<repo-name>
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/e2e/init-project-all.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` to activate one.',
);
}, 60_000);
});
88 changes: 87 additions & 1 deletion src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../projects.js')>()),
loadProjectsManifest: (...args: unknown[]) => mockLoadProjectsManifest(...args),
}));

vi.mock('../roles.js', () => ({
loadRolesManifest: vi.fn().mockResolvedValue({
version: 1,
Expand Down Expand Up @@ -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<typeof import('../utils/prompt.js')>()),
// 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),
Expand Down Expand Up @@ -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 <id>` to activate one.',
);
});
});

/** Init against a clone whose teamai.yaml loads, so the stub deploy runs. */
async function initWithTeamConfig(): Promise<void> {
let cloneDone = false;
Expand Down
108 changes: 90 additions & 18 deletions src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Pick<LocalConfig, 'projects'>> {
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 <id>` 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.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReturnType<typeof promptForRoleProfile>> | 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);
Expand Down Expand Up @@ -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)
Expand Down
Loading