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
2 changes: 2 additions & 0 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ You need the repo URL.
teamai init https://github.com/your-org/your-repo
```
Add `--scope user` to install into your home directory. Every project on this machine then sees the files. Without that flag, files install into this project.

Inside a Git repository, init uses the repository root even when run from a subdirectory. If that root already has a workspace config, run init at the root to change it; a plain subfolder cannot hold a separate config, even with `--force`. A nested repository or linked worktree has its own workspace root.
2. Check it.
```bash
teamai doctor
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/zh-CN/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
teamai init https://github.com/your-org/your-repo
```
加上 `--scope user` 会装到用户主目录,这台机器上的每个项目都能看见。不加的话,文件只进当前项目。

在 Git 仓库中,即使从子目录运行 init,也会使用仓库根目录。如果根目录已有 workspace 配置,要到根目录修改;普通子文件夹不能单独配置,即使加 `--force` 也不能覆盖父配置。嵌套仓库和 linked worktree 各自使用自己的工作区根目录。
2. 检查一下。
```bash
teamai doctor
Expand Down
1 change: 1 addition & 0 deletions skill-data/setup/references/join-member.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ If it fails, Node.js ≥ 20 is missing — have them install Node 20+ first.
## Step 2 — Choose scope (ASK)

- **This project only** (default): `cd` into the project directory first.
Inside Git, init uses the repository root. If it already has a workspace config, re-run init at that root; a plain subfolder cannot replace it, even with `--force`. Nested repositories and linked worktrees keep their own roots.
- **Whole machine**: add `--scope user`.
- **A folder holding several repos** (a workspace): set up each repo from
inside it, then run the same `init` in the folder. The folder also receives
Expand Down
88 changes: 88 additions & 0 deletions src/__tests__/e2e/muse-support.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import YAML from 'yaml';
import { fileURLToPath } from 'node:url';

// ─── Muse Code support ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -146,6 +147,93 @@ describe('Muse Code resource delivery', () => {
expect(JSON.parse(fs.readFileSync(file, 'utf8')).mcpServers).toEqual({ personal });
}, 120_000);

it.each(['repository', 'linked worktree', 'nested repository', 'plain folder'])(
'anchors a subdirectory init to its %s root', async (kind) => {
const home = makeHome(`home-anchor-${kind}-${Date.now()}`);
fs.mkdirSync(path.join(home, '.config', 'muse'), { recursive: true });
const parent = fs.mkdtempSync(path.join(sandbox, `anchor-${kind}-`));
fs.mkdirSync(parent, { recursive: true });
let root = parent;
if (kind !== 'plain folder') git(['init', '-q', '-b', 'main'], parent, home);
if (kind === 'linked worktree') {
git(['commit', '-q', '--allow-empty', '-m', 'fixture'], parent, home);
root = `${parent}-linked`;
git(['worktree', 'add', '-q', '-b', 'linked', root], parent, home);
}
if (kind === 'nested repository') {
root = path.join(parent, 'nested');
fs.mkdirSync(root);
git(['init', '-q', '-b', 'main'], root, home);
}
const cwd = path.join(root, 'packages', 'web');
fs.mkdirSync(cwd, { recursive: true });
const expected = fs.realpathSync.native(kind === 'plain folder' ? cwd : root);
const result = await runCLI(['init', FAKE_URL, '--scope', 'workspace', '--role', 'dev', '--agent', 'muse', '--force'], cwd, home);
expect(result.code, result.output).toBe(0);
expect(result.output).toContain(`Scope: workspace (${expected})`);
expect(fs.existsSync(path.join(expected, '.agents', 'skills', 'teamai', 'SKILL.md'))).toBe(true);
if (kind !== 'plain folder') expect(fs.existsSync(path.join(cwd, '.agents'))).toBe(false);
const pulled = await runCLI(['pull'], cwd, home);
expect(pulled.code, pulled.output).toBe(0);
expect(fs.existsSync(path.join(expected, '.agents', 'skills', 'demo', 'SKILL.md'))).toBe(true);
}, 120_000,
);

it.each([['git', false], ['git', true], ['http', false], ['http', true]] as const)(
'rejects reinitializing an existing ancestor from a plain folder (%s, force=%s)', async (mode, force) => {
const home = makeHome(`home-guard-${mode}-${force}-${Date.now()}`);
fs.mkdirSync(path.join(home, '.config', 'muse'), { recursive: true });
const root = fs.mkdtempSync(path.join(sandbox, `guard-${mode}-${force}-`));
const cwd = path.join(root, 'backend');
fs.mkdirSync(cwd, { recursive: true });
git(['init', '-q', '-b', 'main'], root, home);
const first = await runCLI(['init', FAKE_URL, '--scope', 'workspace', '--role', 'dev', '--agent', 'muse', '--force'], root, home);
expect(first.code, first.output).toBe(0);
const configFiles = () => fs.readdirSync(path.join(home, '.teamai'), { recursive: true })
.filter(file => String(file).endsWith('config.yaml'))
.map(file => [String(file), fs.readFileSync(path.join(home, '.teamai', String(file)), 'utf8')]);
const before = configFiles();
expect(before.length).toBeGreaterThan(0);
const args = mode === 'git' ? ['init', FAKE_URL] : ['init', '--http', 'http://127.0.0.1:1', '--token', 'must-not-be-saved'];
const result = await runCLI([...args, '--scope', 'workspace', '--role', 'ops', '--agent', 'muse', ...(force ? ['--force'] : [])], cwd, home);
expect(result.code, result.output).toBe(1);
expect(result.output).toContain('already has a workspace config');
expect(result.output).toContain(fs.realpathSync.native(root));
expect(configFiles()).toEqual(before);
expect(fs.existsSync(path.join(cwd, '.agents'))).toBe(false);
expect(fs.existsSync(path.join(home, '.teamai', 'apikey'))).toBe(false);
}, 120_000,
);

it.each(['invalid', 'legacy'])('leaves an existing %s ancestor config byte-identical', async (kind) => {
const home = makeHome(`home-existing-${kind}-${Date.now()}`);
fs.mkdirSync(path.join(home, '.config', 'muse'), { recursive: true });
const root = fs.mkdtempSync(path.join(sandbox, `existing-${kind}-`));
const cwd = path.join(root, 'backend');
fs.mkdirSync(cwd);
git(['init', '-q', '-b', 'main'], root, home);
const first = await runCLI(['init', FAKE_URL, '--scope', 'workspace', '--role', 'dev', '--agent', 'muse', '--force'], root, home);
expect(first.code, first.output).toBe(0);
const relative = fs.readdirSync(path.join(home, '.teamai'), { recursive: true })
.find(file => String(file).endsWith('config.yaml'));
expect(relative).toBeDefined();
const file = path.join(home, '.teamai', String(relative));
const config = YAML.parse(fs.readFileSync(file, 'utf8'));
if (kind === 'invalid') fs.writeFileSync(file, 'repo: [broken\n');
else {
delete config.primaryRole;
fs.writeFileSync(file, YAML.stringify(config));
const manifest = path.join(config.repo.localPath, 'manifest', 'roles.yaml');
fs.mkdirSync(path.dirname(manifest), { recursive: true });
fs.writeFileSync(manifest, YAML.stringify({ version: 1, roles: [{ id: 'hai', resources: { knowledge: [], skills: [] } }] }));
}
const before = fs.readFileSync(file, 'utf8');
const result = await runCLI(['init', FAKE_URL, '--scope', 'workspace', '--role', 'ops', '--agent', 'muse', '--force'], cwd, home);
expect(result.code, result.output).toBe(1);
expect(result.output).toContain('already has a workspace config');
expect(fs.readFileSync(file, 'utf8')).toBe(before);
}, 120_000);

it('keeps Muse-normalized MCP entries removable through the real CLI', async () => {
const workDir = path.join(sandbox, 'normalized-work');
fs.mkdirSync(workDir, { recursive: true });
Expand Down
47 changes: 31 additions & 16 deletions src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from 'node:path';
import { readWorkspaceChildren } from './workspace-children.js';
import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js';
import { codexTrustPolicy, deliveredHookFiles, describeUnappliedTeamHooks, hasTeamaiHooks, reconcileHooks, reconcileTeamHooksForConfig, reportCodexTrust, trustCodexForScope } from './hooks.js';
import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, getConfiguredRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, DetachedTeamRepoError, pushRepoDirectly, autoPushViaMR, gitBlockTimeoutMs, createGitWithBlockTimeout } from './utils/git.js';
import { configureGitUser, resolveAnchors, initRepo, isGitRepo, getRemoteUrl, getConfiguredRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, DetachedTeamRepoError, pushRepoDirectly, autoPushViaMR, gitBlockTimeoutMs, createGitWithBlockTimeout } from './utils/git.js';
import { withTimeout } from './utils/async.js';
import { getProvider, detectProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js';
import { parseGenericGitExistingRemote } from './providers/git/repo-url.js';
Expand Down Expand Up @@ -574,12 +574,35 @@ async function newTeamConfigProvider(input: string, providerName: string, forced
);
}

/** Init and later readers must agree on the repository that owns a workspace. */
async function resolveInitContext(scopeFlag: string | undefined) {
const cwd = await fs.promises.realpath(process.cwd());
const root = scopeFlag === 'user' ? cwd : (await resolveAnchors(cwd))?.workspaceRoot ?? cwd;
const resolved = resolveInitScope(scopeFlag, root, getUserHome());
const inside = resolved.scope === 'project' && resolved.projectRoot !== cwd;
// A rejected init must not migrate roles, adopt a partition or bootstrap the ancestor.
const existingLocalConfig = await loadLocalConfigForScope(resolved.scope, resolved.projectRoot,
inside ? { dryRun: true, suppressMigrationNotice: true, selfHeal: false } : undefined);
const existingFile = inside && resolved.projectRoot && (
await pathExists(path.join(await resolveProjectDataHome(resolved.projectRoot, { dryRun: true }), 'config.yaml'))
|| await pathExists(path.join(resolved.projectRoot, '.teamai', 'config.yaml'))
);
if (inside && (existingLocalConfig || existingFile)) {
throw new Error(
`${cwd} is inside the repository ${resolved.projectRoot}, which already has a workspace config. `
+ 'A plain folder inside a repository cannot have its own config; only repositories are children of a workspace. '
+ `Run teamai init in ${resolved.projectRoot} to change that config, or make ${cwd} a repository of its own.`,
);
}
return { ...resolved, existingLocalConfig };
}

function printScopeSummary(
scope: Scope,
projectRoot: string | undefined,
explicit: boolean,
configPath: string,
): void {
const configPath = getConfigPath(scope, projectRoot);
const baseDir = scope === 'project' ? (projectRoot ?? process.cwd()) : getUserHome();
log.info(`Scope: ${installScopeName(scope)}${scope === 'project' ? ` (${projectRoot})` : ''}`);
log.info(` config → ${configPath}`);
Expand Down Expand Up @@ -619,18 +642,14 @@ export async function initHttp(
let projectRoot: string | undefined;
let explicit: boolean;
let fallbackReason: string | undefined;
let existingLocalConfig: LocalConfig | null;
try {
({ scope, projectRoot, explicit, fallbackReason } = resolveInitScope(
options.scope,
process.cwd(),
getUserHome(),
));
({ scope, projectRoot, explicit, fallbackReason, existingLocalConfig } = await resolveInitContext(options.scope));
} catch (e) {
log.error((e as Error).message);
process.exit(1);
return;
}
const existingLocalConfig = await loadLocalConfigForScope(scope, projectRoot);
let inheritUserScope: boolean | undefined;
try {
inheritUserScope = resolveInheritUserScope(
Expand All @@ -649,7 +668,7 @@ export async function initHttp(
const teamaiHome = scope === 'project' && projectRoot
? (existingLocalConfig?.dataHome ?? await resolveProjectDataHome(projectRoot))
: getTeamaiHome(scope, projectRoot);
printScopeSummary(scope, projectRoot, explicit);
printScopeSummary(scope, projectRoot, explicit, path.join(teamaiHome, 'config.yaml'));

if (scope === 'project' && !(await isInsideGitRepo(process.cwd()))) {
log.warn(`cwd is not inside a git repository; will create ${teamaiHome}/`);
Expand Down Expand Up @@ -1581,18 +1600,14 @@ export async function init(options: GlobalOptions & {
let projectRoot: string | undefined;
let explicit: boolean;
let fallbackReason: string | undefined;
let existingLocalConfig: LocalConfig | null;
try {
({ scope, projectRoot, explicit, fallbackReason } = resolveInitScope(
options.scope,
process.cwd(),
getUserHome(),
));
({ scope, projectRoot, explicit, fallbackReason, existingLocalConfig } = await resolveInitContext(options.scope));
} catch (e) {
log.error((e as Error).message);
process.exit(1);
return;
}
const existingLocalConfig = await loadLocalConfigForScope(scope, projectRoot);
const teamaiHome = scope === 'project' && projectRoot
? (existingLocalConfig?.dataHome ?? await resolveProjectDataHome(projectRoot))
: getTeamaiHome(scope, projectRoot);
Expand All @@ -1616,7 +1631,7 @@ export async function init(options: GlobalOptions & {
if (fallbackReason) {
log.warn(fallbackReason);
}
printScopeSummary(scope, projectRoot, explicit);
printScopeSummary(scope, projectRoot, explicit, path.join(teamaiHome, 'config.yaml'));

if (scope === 'project' && !(await isInsideGitRepo(process.cwd()))) {
log.warn(`cwd is not inside a git repository; will create ${teamaiHome}/`);
Expand Down
Loading