diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fe982ad..d4fa5d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,13 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features +- `teamai members --invite` generates an AI-ready onboarding invitation that keeps Git commands and secrets away from non-technical members, then verifies a team skill in a fresh session ([#517](https://github.com/Tencent/teamai-cli/issues/517)). - Multi-project management: `role` and `project` together resolve resource namespaces, and project-private learnings are isolated ([#426](https://github.com/Tencent/teamai-cli/pull/426), for [#375](https://github.com/Tencent/teamai-cli/issues/375)). - Data partitions auto-migrate a legacy `.teamai`, resume interrupted migrations, smoke-check the clone, and keep a git-ignored backup ([#439](https://github.com/Tencent/teamai-cli/pull/439), for [#374](https://github.com/Tencent/teamai-cli/issues/374)). ### 🐛 Bug Fixes +- `teamai doctor` no longer assumes TGit before initialization and now exits with code 1 when any diagnostic check fails ([#517](https://github.com/Tencent/teamai-cli/issues/517)). - MCP `requires` is resolved from `PATH` (including Windows `PATHEXT`), so `teamai mcp inject` no longer skips servers such as `uvx` on Windows ([#540](https://github.com/Tencent/teamai-cli/pull/540), for [#539](https://github.com/Tencent/teamai-cli/issues/539)). - The GitHub and CNB providers resolve their CLI to a launchable absolute path and start it through cross-spawn, so on Windows they no longer answer "installed" while every call fails silently ([#520](https://github.com/Tencent/teamai-cli/pull/520)). - `enabledAgents` now also gates CLI builtin deploy, CLAUDE.md-class injects, and last-pull skip-sync targets, so an already-installed tool outside the whitelist is not written to ([#510](https://github.com/Tencent/teamai-cli/issues/510)). diff --git a/README.md b/README.md index 419e4d48..326d3d93 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,16 @@ npm install -g teamai-cli Create a shared-experience repo on your git host (GitHub, GitLab, GitCode, CNB, TGit, or a private Git service), **grant write access to team members**, then run `teamai init https://github.com/yourorg/yourrepo`. +After initialization, run `teamai members --invite` and send the generated invitation to each member. It guides their AI tool through a no-Git-command, no-secret-in-chat setup and verifies the first skill in a fresh session. + > **No team repo yet?** Start from a template pre-loaded with production-ready skills, rules, and review agents. Browse the [teamai-hub](https://github.com/teamai-hub) org, click **Use this template**, then `teamai init` against your new repo. ### Team members +Paste the invitation from your admin into the AI tool you want to equip. Let it run the setup; onboarding is complete only after `teamai doctor` passes, a local team skill is listed, and that skill responds in a fresh session. + +If your admin sent only the repo URL, the manual fallback is: + ```bash # Choose one, depending on where you want resources installed @@ -254,7 +260,7 @@ Insight into how the team actually uses its AI tools, and a starting point for t | `teamai codebase --reconcile` | Reconcile product documentation with extracted code knowledge | | `teamai codebase --lint` | Knowledge graph health check | | `teamai ci extract-mr --url ` | CI: extract knowledge from MR, post comments, write after merge | -| `teamai members` | List team members | +| `teamai members [--invite]` | List team members or print an AI-ready onboarding invitation | | `teamai projects` | Bind a working directory to one or more logical projects | | `teamai roles` | Manage team roles and namespaces | | `teamai tags` | Manage tag-based skill/rule filtering | @@ -263,7 +269,7 @@ Insight into how the team actually uses its AI tools, and a starting point for t | `teamai remove ` | Remove a resource and open MR | | `teamai session save` | Record a privacy-scrubbed session summary to a monthly log (`--push` feeds `digest`) | | `teamai digest` | Generate weekly team usage digest | -| `teamai doctor` | Diagnose configuration issues | +| `teamai doctor` | Diagnose configuration issues; exits with code 1 when any check fails | | `teamai uninstall` | Remove all teamai resources and hooks | ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 9b1d36fa..de0b4927 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,10 +35,16 @@ npm install -g teamai-cli 在 Git 托管平台(GitHub、GitLab、GitCode、CNB、TGit,或私有 Git 服务)创建共享经验仓库,**授予团队成员写权限**,然后运行 `teamai init https://github.com/yourorg/yourrepo`。 +初始化后运行 `teamai members --invite`,把生成的邀请信息发给成员。成员无需输入 Git 命令或在对话中粘贴密钥,AI 工具会完成接入,并在新会话中验证首个 Skill。 + > **还没有团队仓库?** 可以从内置了成套 skills、rules、review agents 的模板起步。浏览 [teamai-hub](https://github.com/teamai-hub) org,点 **Use this template** 生成自己的仓库,再对它执行 `teamai init`。 ### 团队成员 +把管理员发来的邀请信息粘贴到你想使用的 AI 工具中,让它代为完成接入。只有 `teamai doctor` 通过、本地能列出团队 Skill,且该 Skill 在新会话中实际响应,才算接入完成。 + +如果管理员只发了仓库地址,可按以下方式手动接入: + ```bash # 二选一:按你想要的安装范围选择其中一条 @@ -254,7 +260,7 @@ teamai recall maintenance --update-quality # 为过时 skills / docs 生 | `teamai codebase --reconcile` | 将产品文档与提取的代码知识进行对账 | | `teamai codebase --lint` | 知识图谱健康检查 | | `teamai ci extract-mr --url ` | CI:从 MR 提取知识、发评论、合并后写入 | -| `teamai members` | 查看团队成员 | +| `teamai members [--invite]` | 查看团队成员或生成可交给 AI 执行的接入邀请 | | `teamai projects` | 将工作目录绑定到一个或多个逻辑项目 | | `teamai roles` | 管理团队角色和命名空间 | | `teamai tags` | 管理基于标签的 skill/rule 过滤 | @@ -263,7 +269,7 @@ teamai recall maintenance --update-quality # 为过时 skills / docs 生 | `teamai remove ` | 删除资源并创建 MR | | `teamai session save` | 将脱敏后的 session 摘要记录到月度日志(`--push` 可喂给 `digest`) | | `teamai digest` | 生成团队周报 | -| `teamai doctor` | 诊断配置问题 | +| `teamai doctor` | 诊断配置问题;任一检查失败时以状态码 1 退出 | | `teamai uninstall` | 移除所有 teamai 资源和 hooks | ## 许可证 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 760b4af4..af65ed08 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -360,7 +360,17 @@ With inheritance enabled, `teamai pull` refreshes user `skills`, `rules`, `docs` ## Member Onboarding -Once the admin shares the team repo URL with members: +The admin generates a self-contained invitation after initialization: + +```bash +teamai members --invite +``` + +The member pastes the invitation into the AI tool they want to equip. The AI performs setup without asking the member to type Git commands or paste credentials into chat. It uses the provider's official sign-in flow and asks only for decisions it cannot infer. + +Onboarding has one user-centered success contract: within eight minutes, `teamai doctor` exits with code 0, `teamai list skills --source local --agent ` lists at least one team skill, and the member confirms that skill responds in a fresh AI session. + +If the admin shares only the team repo URL, use this manual fallback: **Project-scoped teams (default):** @@ -394,6 +404,8 @@ teamai init --http https://your-team-host/api --token **Verify:** ```bash +teamai doctor # All checks pass; exit code 0 +teamai list skills --source local --agent claude teamai status # View status teamai members # View team members teamai list # All resource types (skills|rules|docs|env|agents|hooks|mcp) + local skills @@ -465,7 +477,7 @@ The existing SessionStart hook runs `teamai pull`. When the `packages` declarati ```bash teamai packages # Install every team declaration teamai packages --dry-run # Preview native commands without installing or writing files -teamai doctor # Check runtimes and declared package/marketplace/plugin status +teamai doctor # Check setup; exits 1 when any check fails ``` After a successful install, TeamAI writes a local snapshot to `teamai.lock` under the active scope's `.teamai` directory. The lock records installed versions and the declaration hash used by the SessionStart hint; it is not stored in the team repository. In user scope, machine-wide npm tools and Claude plugins are acknowledged once, while project npm dependencies are acknowledged separately for each working directory so installing in one repository cannot silence another repository's hint. @@ -1412,6 +1424,8 @@ teamai remove agents teamai remove mcp ``` +`teamai doctor` exits with code 0 only when every check passes, and code 1 when any check fails. Before initialization, it reports the missing configuration without assuming a Git provider. + Auto-update runs in the Stop hook and is controlled by two tiers: | Tier | File | Field | Value | diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index d3a01fa8..2c718845 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -345,7 +345,17 @@ teamai init https://github.com/yourorg/java-service-teamai --inherit-user-scope ## 成员接入 -管理员将团队仓库地址分享给成员后: +管理员完成初始化后,生成一段自包含的邀请信息: + +```bash +teamai members --invite +``` + +成员把邀请信息粘贴到想使用的 AI 工具中。AI 会代为完成接入,不要求成员输入 Git 命令,也不要求把凭据粘贴进对话;认证使用托管平台的官方登录流程,只询问无法自行判断的选择。 + +接入只有一个以用户结果为中心的验收标准:8 分钟内,`teamai doctor` 以状态码 0 退出,`teamai list skills --source local --agent ` 至少列出一个团队 Skill,并且成员确认该 Skill 在新的 AI 会话中实际响应。 + +如果管理员只分享了团队仓库地址,可按以下方式手动接入: **项目级团队(默认):** @@ -379,6 +389,8 @@ teamai init --http https://your-team-host/api --token **验证:** ```bash +teamai doctor # 所有检查通过;退出码为 0 +teamai list skills --source local --agent claude teamai status # 查看状态 teamai members # 查看团队成员 teamai list # 全部资源类型(skills|rules|docs|env|agents|hooks|mcp)+ 本地 skills @@ -450,7 +462,7 @@ Claude 插件 target 使用 `plugin@marketplace` 格式。`claude-plugins-offici ```bash teamai packages # 安装团队声明的全部包和插件 teamai packages --dry-run # 预览底层命令,不安装也不写文件 -teamai doctor # 检查运行环境及声明的包、marketplace、插件状态 +teamai doctor # 检查接入状态;任一检查失败时退出码为 1 ``` 安装成功后,TeamAI 会在当前 scope 的 `.teamai` 目录下写入本地快照 `teamai.lock`。该文件记录已安装版本,以及供 SessionStart 提示比对的声明哈希,不会写入团队仓库。在 user scope 下,全局 npm 工具和 Claude 插件只需确认一次;项目 npm 依赖会按工作目录分别确认,避免在一个仓库安装后错误关闭另一个仓库的提示。 @@ -1379,6 +1391,8 @@ teamai remove agents teamai remove mcp ``` +仅当所有检查通过时,`teamai doctor` 才以状态码 0 退出;任一检查失败时以状态码 1 退出。尚未初始化时,它只报告缺少配置,不会臆测 Git 托管平台。 + 自动更新在 Stop hook 中执行,可通过两层控制: | 层级 | 文件 | 字段 | 值 | diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index 883ab5d0..cea63a48 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import path from 'node:path'; // ── Mocks ──────────────────────────────────────────────── @@ -35,6 +36,7 @@ import { loadLocalConfig, loadTeamConfig } from '../config.js'; import { pathExists, readFileSafe } from '../utils/fs.js'; import { TEAMAI_HOOK_SUBCOMMANDS } from '../hooks.js'; import { log } from '../utils/logger.js'; +import { isGfInstalled, gfIsAuthenticated } from '../providers/tgit/index.js'; import { doctor } from '../doctor.js'; const mockedLoadLocalConfig = loadLocalConfig as Mock; @@ -42,6 +44,8 @@ const mockedLoadTeamConfig = loadTeamConfig as Mock; const mockedPathExists = pathExists as Mock; const mockedReadFileSafe = readFileSafe as Mock; const mockedLog = log as unknown as { info: Mock; success: Mock; warn: Mock; error: Mock; debug: Mock }; +const mockedIsGfInstalled = isGfInstalled as Mock; +const mockedGfIsAuthenticated = gfIsAuthenticated as Mock; const mockLocalConfig = { repo: { localPath: '/tmp/repo', remote: 'https://git.woa.com/team/repo.git' }, @@ -92,12 +96,17 @@ beforeEach(() => { describe('doctor — hook checks', () => { it('should pass when all subcommands are present in settings', async () => { - await doctor({}); + mockedLoadTeamConfig.mockResolvedValue({ + ...mockTeamConfig, + sharing: { env: { injectShellProfile: false } }, + }); + const allPassed = await doctor({}); // Should show the hooks check passing (✔) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('✔'), ); + expect(allPassed).toBe(true); }); it('should fail when a subcommand is missing from settings', async () => { @@ -113,7 +122,7 @@ describe('doctor — hook checks', () => { return null; }); - await doctor({}); + const allPassed = await doctor({}); // Should show the hooks check failing (✖) with fix suggestion expect(consoleSpy).toHaveBeenCalledWith( @@ -122,6 +131,7 @@ describe('doctor — hook checks', () => { expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('teamai hooks inject'), ); + expect(allPassed).toBe(false); }); it('should fail when settings file does not exist', async () => { @@ -180,7 +190,7 @@ describe('doctor — hook checks', () => { // Only carries the hooks (committed to the business repo). mockedReadFileSafe.mockImplementation(async (filePath: string) => { if (filePath.includes('settings.json')) { - return filePath.includes(projectRoot) ? buildFullHooksContent() : '{ "hooks": {} }'; + return filePath.includes(path.normalize(projectRoot)) ? buildFullHooksContent() : '{ "hooks": {} }'; } return null; }); @@ -192,7 +202,7 @@ describe('doctor — hook checks', () => { it('should pass env check when env/env.yaml does not exist in team repo', async () => { mockedPathExists.mockImplementation(async (filePath: string) => { - if (filePath.includes('env/env.yaml')) return false; + if (filePath.endsWith(path.join('env', 'env.yaml'))) return false; return true; }); mockedReadFileSafe.mockImplementation(async (filePath: string) => { @@ -282,4 +292,42 @@ describe('doctor — hook checks', () => { // Should still show claude check expect(allCalls.some((msg: string) => msg.includes('claude'))).toBe(true); }); + + it('does not assume a provider before initialization', async () => { + mockedLoadLocalConfig.mockResolvedValue(null); + mockedLoadTeamConfig.mockResolvedValue(null); + + const allPassed = await doctor({}); + + const allLines = consoleSpy.mock.calls.map((c) => String(c[0])); + expect(allLines).toContain(' Scope: not initialized\n'); + expect(allLines).toContain(' ✖ TeamAI is not initialized'); + expect(allLines.some((line) => line.includes('gf CLI'))).toBe(false); + expect(allLines.some((line) => line.includes('hooks in'))).toBe(false); + expect(mockedIsGfInstalled).not.toHaveBeenCalled(); + expect(mockedGfIsAuthenticated).not.toHaveBeenCalled(); + expect(allPassed).toBe(false); + }); + + it('checks hooks only for agents selected during onboarding', async () => { + mockedLoadLocalConfig.mockResolvedValue({ + ...mockLocalConfig, + enabledAgents: ['claude'], + }); + mockedLoadTeamConfig.mockResolvedValue({ + ...mockTeamConfig, + sharing: { env: { injectShellProfile: false } }, + toolPaths: { + claude: { settings: '.claude/settings.json', skills: '.claude/skills' }, + codex: { settings: '.codex/hooks.json', skills: '.codex/skills' }, + }, + }); + + const allPassed = await doctor({}); + + const allLines = consoleSpy.mock.calls.map((c) => String(c[0])); + expect(allLines.some((line) => line.includes('hooks in claude settings'))).toBe(true); + expect(allLines.some((line) => line.includes('hooks in codex settings'))).toBe(false); + expect(allPassed).toBe(true); + }); }); diff --git a/src/__tests__/members.test.ts b/src/__tests__/members.test.ts index 8342b150..a6f18185 100644 --- a/src/__tests__/members.test.ts +++ b/src/__tests__/members.test.ts @@ -12,6 +12,7 @@ vi.mock('../config.js', () => ({ vi.mock('../utils/git.js', () => ({ pullRepo: vi.fn().mockResolvedValue('Already up to date.'), + redactGitCredentials: vi.fn((url: string) => url.replace(/\/\/[^/@]+@/, '//')), })); vi.mock('../utils/logger.js', () => ({ @@ -32,7 +33,7 @@ vi.mock('../utils/logger.js', () => ({ })), })); -import { getMemberConfig, listMembers, mergeMemberConfig } from '../members.js'; +import { buildMemberInvite, getMemberConfig, listMembers, mergeMemberConfig, printMemberInvite } from '../members.js'; import { requireInit } from '../config.js'; import { log } from '../utils/logger.js'; @@ -57,6 +58,66 @@ scope: 'user', }); } +describe('member invitation', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.mocked(log.error).mockClear(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('builds a self-contained, verifiable invitation without embedded credentials', () => { + const invite = buildMemberInvite('https://oauth2:secret@example.com/team/repo.git'); + + expect(invite).toContain('Goal: within 8 minutes'); + expect(invite).toContain('https://example.com/team/repo.git'); + expect(invite).not.toContain('secret'); + expect(invite).toContain('Never ask me to paste a password, token, or key into chat'); + expect(invite).toContain('teamai doctor'); + expect(invite).toContain('exits with code 0'); + expect(invite).toContain('teamai list skills --source local --agent '); + expect(invite).toContain('fresh session'); + expect(invite).toContain('I confirm the skill responded'); + }); + + it('prints an invitation from the configured repo', async () => { + mockRequireInit('/tmp/team-repo'); + + await expect(printMemberInvite()).resolves.toBe(true); + + const output = consoleSpy.mock.calls.map((call) => String(call[0])).join('\n'); + expect(output).toContain('https://git.woa.com/team/repo.git'); + }); + + it('explains how an uninitialized admin can create an invitation', async () => { + vi.mocked(requireInit).mockRejectedValue(new Error('not initialized')); + + await expect(printMemberInvite()).resolves.toBe(false); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('teamai init ')); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('refuses to put an HTTP API key workflow into a pasteable invitation', async () => { + mockRequireInit('/tmp/team-repo'); + const initResult = await requireInit(); + vi.mocked(requireInit).mockResolvedValue({ + ...initResult, + localConfig: { + ...initResult.localConfig, + repo: { ...initResult.localConfig.repo, kind: 'http' }, + }, + }); + + await expect(printMemberInvite()).resolves.toBe(false); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('API key must be shared out of band')); + expect(consoleSpy).not.toHaveBeenCalled(); + }); +}); + describe('getMemberConfig', () => { let tmpDir: string; diff --git a/src/doctor.ts b/src/doctor.ts index 36724380..03c48913 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -2,12 +2,13 @@ import path from 'node:path'; import { detectProjectConfig, loadLocalConfig, loadTeamConfig } from './config.js'; import { pathExists, readFileSafe } from './utils/fs.js'; import { log } from './utils/logger.js'; -import type { GlobalOptions, Scope } from './types.js'; +import type { GlobalOptions } from './types.js'; import { - TeamaiConfigSchema, TEAMAI_ENV_START, resolveHookScope, getDataHome, + isAgentExcluded, + scopedToolPaths, type TeamaiConfig, } from './types.js'; import { TEAMAI_HOOK_SUBCOMMANDS, isCodexTrustGatedTool, codexTrustReminder } from './hooks.js'; @@ -66,25 +67,37 @@ async function hasInstalledCodexHooks(toolPaths: TeamaiConfig['toolPaths'], base return false; } -export async function doctor(options: GlobalOptions): Promise { +export async function doctor(options: GlobalOptions): Promise { log.info('Running diagnostics...\n'); const projectConfig = await detectProjectConfig(); const localConfig = projectConfig ?? (await loadLocalConfig()); - const scope: Scope = localConfig?.scope ?? 'user'; + if (!localConfig) { + console.log(' Scope: not initialized\n'); + console.log(' ✖ TeamAI is not initialized'); + console.log(' → Run `teamai init ` in a project, or add `--scope user` for all projects'); + console.log(''); + log.warn('Initialization is required before diagnostics can run.'); + return false; + } + + const scope = localConfig.scope ?? 'user'; const configPathLabel = projectConfig ? `${projectConfig.projectRoot}/.teamai/config.yaml` : '~/.teamai/config.yaml'; - console.log(` Scope: ${scope}${scope === 'project' && localConfig?.projectRoot ? ` (${localConfig.projectRoot})` : ''}\n`); + const scopeLabel = `${scope}${scope === 'project' && localConfig.projectRoot ? ` (${localConfig.projectRoot})` : ''}`; + console.log(` Scope: ${scopeLabel}\n`); // Try to load team config for dynamic tool paths and provider let teamConfig: TeamaiConfig | null = null; - if (localConfig) { - teamConfig = await loadTeamConfig(localConfig.repo.localPath); - } - // Fall back to schema defaults if team config is unavailable - const toolPaths = teamConfig?.toolPaths ?? TeamaiConfigSchema.shape.toolPaths.parse(undefined); - const providerName = teamConfig?.provider ?? 'tgit'; + teamConfig = await loadTeamConfig(localConfig.repo.localPath); + const toolPaths: TeamaiConfig['toolPaths'] = teamConfig + ? Object.fromEntries( + Object.entries(scopedToolPaths(teamConfig, localConfig)) + .filter(([tool]) => !isAgentExcluded(localConfig, tool)), + ) + : {}; + const providerName = teamConfig?.provider; // Hook checks must look where hooks are actually injected. resolveHookScope // maps a non-self project scope to HOME (#264), matching the injection path in // init/pull/hooks-cmd — otherwise doctor checks /.claude while the @@ -235,4 +248,5 @@ export async function doctor(options: GlobalOptions): Promise { } else { log.warn('Some checks failed. See suggestions above.'); } + return allPassed; } diff --git a/src/index.ts b/src/index.ts index e2badc24..f41eeb2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -185,11 +185,17 @@ excludeCmd const membersCmd = program .command('members') .description('Manage team members') - .action(async () => { + .option('--invite', 'Print an AI-ready onboarding invitation for a teammate') + .action(async (cmdOpts: { invite?: boolean }) => { // Default action: list members (backward compatible) const globalOpts = program.opts() as GlobalOptions; - const { listMembers } = await import('./members.js'); - await listMembers(globalOpts); + const { listMembers, printMemberInvite } = await import('./members.js'); + if (cmdOpts.invite) { + const printed = await printMemberInvite(); + if (!printed) process.exitCode = 1; + } else { + await listMembers(globalOpts); + } }); membersCmd @@ -218,7 +224,8 @@ program .action(async () => { const globalOpts = program.opts() as GlobalOptions; const { doctor } = await import('./doctor.js'); - await doctor(globalOpts); + const allPassed = await doctor(globalOpts); + if (!allPassed) process.exitCode = 1; }); // ─── Roles subcommand ───────────────────────────────────── diff --git a/src/init.ts b/src/init.ts index ca79ad35..3b579b66 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1509,6 +1509,7 @@ export async function init(options: GlobalOptions & { log.info('Built-in skills (e.g. team-wiki-codebase) are ready to use in your IDE now.'); log.info('Skills, rules, env and docs will auto-sync on each session start (via hooks).'); log.info('Run `teamai status` to check current config.'); + log.info('Invite a teammate with `teamai members --invite`.'); // Close the readline singleton so the process can exit cleanly. closePrompt(); diff --git a/src/members.ts b/src/members.ts index 7e4cc12f..201e912b 100644 --- a/src/members.ts +++ b/src/members.ts @@ -2,10 +2,68 @@ import YAML from 'yaml'; import path from 'node:path'; import { requireInit, detectProjectConfig } from './config.js'; import { readFileSafe, listFiles } from './utils/fs.js'; -import { pullRepo } from './utils/git.js'; +import { pullRepo, redactGitCredentials } from './utils/git.js'; import { log } from './utils/logger.js'; import { MemberConfigSchema } from './types.js'; -import type { GlobalOptions, MemberConfig } from './types.js'; +import type { GlobalOptions, LocalConfig, MemberConfig } from './types.js'; + +export function buildMemberInvite(repoUrl: string): string { + const safeRepoUrl = redactGitCredentials(repoUrl.trim()); + if (!safeRepoUrl) throw new Error('Team repo URL is missing from local config.'); + + return [ + 'TeamAI member invitation', + '', + 'Paste everything between BEGIN and END into the AI tool you want to use:', + '', + '----- BEGIN TEAMAI INVITE -----', + 'Help me join my team\'s TeamAI setup.', + `Team repo: ${safeRepoUrl}`, + '', + 'Goal: within 8 minutes, make at least one team skill available in this AI tool.', + '', + 'Rules:', + '- Run the setup for me. Do not ask me to type or understand Git commands, and do not run raw `git` commands; use `teamai`.', + '- Never ask me to paste a password, token, or key into chat. Use the provider\'s official browser/device login. If that is unavailable, pause and tell me how to enter the credential outside chat.', + '- Do not create another team repo.', + '- Detect my OS, Node.js version, current folder, current AI tool, and existing login before asking questions. Ask only for decisions you cannot infer, and recommend a default.', + '- Use project scope only after confirming the current folder is the project I want to equip. Otherwise ask me to open that project first.', + '', + 'Steps:', + '1. Ensure Node.js 20 or newer is available, then install `teamai-cli` globally if needed.', + '2. Infer the provider from the repo URL and complete its official sign-in flow if needed.', + '3. Identify the TeamAI agent id for this AI tool, then run `teamai init "' + safeRepoUrl + '" --agent ` from the target project.', + '4. Run `teamai doctor` and resolve actionable failures until it exits with code 0.', + '5. Run `teamai list skills --source local --agent ` and verify that at least one skill is listed.', + '6. Tell me the exact skill name and one natural-language sentence to invoke it. Ask me to open a fresh session of this AI tool, use that sentence, and confirm the skill responded.', + '', + 'Do not declare onboarding complete until steps 4 and 5 pass and I confirm the skill responded in the fresh session.', + '----- END TEAMAI INVITE -----', + ].join('\n'); +} + +export async function printMemberInvite(): Promise { + let localConfig: LocalConfig; + try { + const projectConfig = await detectProjectConfig(); + localConfig = projectConfig ?? (await requireInit()).localConfig; + } catch { + log.error('TeamAI is not initialized. Run `teamai init ` first, then rerun `teamai members --invite`.'); + return false; + } + + if (localConfig.repo.kind === 'self') { + log.error('AI-ready member invites are not available in single-repo mode; teammates receive TeamAI when they clone the business repo.'); + return false; + } + if (localConfig.repo.kind === 'http') { + log.error('AI-ready member invites are not available in HTTP mode because the API key must be shared out of band.'); + return false; + } + + console.log(buildMemberInvite(localConfig.repo.remote)); + return true; +} /** * Read a specific member's config from the repo.