diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ed184c1..34d29f9 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -202,12 +202,14 @@ teamai init --http https://your-team-host/api --token ```bash teamai status # View status teamai members # View team members -teamai list # View team repo + skills installed by each AI agent (default --source all) -teamai list --source repo # Only view team repo contents (legacy behavior) -teamai list --source local # Only view skills under each installed agent, labeled by source -teamai list --agent claude --verbose # Only view skills installed by Claude Code, with descriptions - -teamai skill # List all skills (equivalent to teamai list skills --source all) +teamai list # All resource types (skills|rules|docs|env|agents|hooks|mcp) + local skills +teamai list mcp # Only team MCP servers +teamai list --source repo # Team repo only +teamai list --source local # Skills under each installed agent +teamai list --agent claude --verbose +teamai list env --reveal # Show env values in plaintext (default: masked) + +teamai skill # Equivalent to teamai list skills --source all teamai skill show hai-deploy-test # View a single skill's source / contributor / install locations / description summary ``` diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index dc16dec..d5070ec 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -200,12 +200,14 @@ teamai init --http https://your-team-host/api --token ```bash teamai status # 查看状态 teamai members # 查看团队成员 -teamai list # 查看团队仓库 + 各 AI agent 已安装的 skills(默认 --source all) -teamai list --source repo # 只看团队仓库内容(旧行为) -teamai list --source local # 只看每个已安装 agent 下的 skills,按来源标注 -teamai list --agent claude --verbose # 只看 Claude Code 安装的 skills,含描述 - -teamai skill # 列出所有 skill(等价于 teamai list skills --source all) +teamai list # 全部资源类型(skills|rules|docs|env|agents|hooks|mcp)+ 本地 skills +teamai list mcp # 只看团队 MCP servers +teamai list --source repo # 只看团队仓库 +teamai list --source local # 各已安装 agent 下的 skills +teamai list --agent claude --verbose +teamai list env --reveal # 明文显示 env(默认脱敏) + +teamai skill # 等价于 teamai list skills --source all teamai skill show hai-deploy-test # 看单个 skill 的来源 / 贡献者 / 安装位置 / 描述摘要 ``` diff --git a/src/__tests__/status-list.test.ts b/src/__tests__/status-list.test.ts new file mode 100644 index 0000000..eeb1428 --- /dev/null +++ b/src/__tests__/status-list.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +const mockAutoDetectInit = vi.fn(); + +vi.mock('../config.js', () => ({ + autoDetectInit: (...args: unknown[]) => mockAutoDetectInit(...args), + loadStateForScope: vi.fn(async () => ({})), +})); + +vi.mock('../utils/git.js', () => ({ + getRepoStatus: vi.fn(async () => ({ ahead: 0, behind: 0, modified: [] })), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, +})); + +import { list, status } from '../status.js'; +import type { TeamaiConfig, LocalConfig } from '../types.js'; +import { log } from '../utils/logger.js'; + +function makeTeamConfig(): TeamaiConfig { + return { + team: 'test', + description: '', + repo: 'https://example.com/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '~/.teamai/docs' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + claude: { + skills: '.claude/skills', + rules: '.claude/rules', + settings: '.claude/settings.json', + claudemd: '.claude/CLAUDE.md', + agents: '.claude/agents', + mcp: '.claude.json', + mcpProject: '.mcp.json', + }, + }, + }; +} + +describe('teamai list / status resource coverage', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + let lines: string[]; + let spy: ReturnType; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-list-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'repo'); + vi.stubEnv('HOME', homeDir); + + await fse.ensureDir(path.join(repoPath, 'skills')); + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.ensureDir(path.join(repoPath, 'mcp')); + await fse.ensureDir(path.join(repoPath, 'hooks')); + await fse.ensureDir(path.join(repoPath, 'agents')); + await fse.ensureDir(path.join(repoPath, 'env')); + + await fse.writeFile( + path.join(repoPath, 'env', 'env.yaml'), + 'variables:\n - key: SECRET_TOKEN\n value: super-secret-value\n', + ); + await fse.writeFile( + path.join(repoPath, 'mcp', 'mcp.yaml'), + [ + 'servers:', + ' - name: gpu-analysis', + ' transport: http', + ' url: https://example.com/mcp', + ].join('\n'), + ); + await fse.writeFile( + path.join(repoPath, 'hooks', 'hooks.yaml'), + [ + 'hooks:', + ' - id: marker-hook', + ' description: e2e marker', + ' event: Stop', + ' command: echo hi', + ].join('\n'), + ); + await fse.writeFile(path.join(repoPath, 'agents', 'reviewer.md'), '# Reviewer\n'); + + const localConfig: LocalConfig = { + repo: { localPath: repoPath, remote: 'https://example.com/repo.git' }, + username: 'u', + updatePolicy: 'auto', + scope: 'user', + additionalRoles: [], + }; + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig() }); + + lines = []; + spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + }); + + afterEach(async () => { + spy.mockRestore(); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('default list includes mcp, agents, hooks and masks env values', async () => { + await list(undefined, { source: 'repo' }); + const out = lines.join('\n'); + + expect(out).toContain('=== REPO MCP ==='); + expect(out).toContain('gpu-analysis [http]'); + expect(out).toContain('=== REPO AGENTS ==='); + expect(out).toContain('reviewer'); + expect(out).toContain('=== REPO HOOKS ==='); + expect(out).toContain('marker-hook [Stop]'); + expect(out).toContain('=== REPO ENV ==='); + expect(out).toContain('SECRET_TOKEN=su****'); + expect(out).not.toContain('super-secret-value'); + }); + + it('list env --reveal shows plaintext', async () => { + await list('env', { source: 'repo', reveal: true }); + const out = lines.join('\n'); + expect(out).toContain('SECRET_TOKEN=super-secret-value'); + }); + + it('list rejects unknown types', async () => { + await list('widgets', { source: 'repo' }); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Unknown resource type')); + }); + + it('status counts include agents, hooks, and mcp', async () => { + await status({}); + const out = lines.join('\n'); + expect(out).toMatch(/agents:\s*1/); + expect(out).toMatch(/hooks:\s*1/); + expect(out).toMatch(/mcp:\s*1/); + }); +}); diff --git a/src/env-commands.ts b/src/env-commands.ts index 0d21648..cfd8ac1 100644 --- a/src/env-commands.ts +++ b/src/env-commands.ts @@ -4,23 +4,11 @@ import { requireInit, detectProjectConfig } from './config.js'; import { pullRepo } from './utils/git.js'; import { ensureDir, readFileSafe, writeFile, pathExists } from './utils/fs.js'; import { log, spinner } from './utils/logger.js'; -import { EnvHandler } from './resources/env.js'; +import { EnvHandler, maskEnvValue } from './resources/env.js'; import type { GlobalOptions } from './types.js'; const envHandler = new EnvHandler(); -/** - * Mask an env variable value for display. - * Shows first 2 chars + "****", or "****" for very short values. - * - * @param value Original value string. - * @returns Masked string. - */ -function maskValue(value: string): string { - if (value.length < 4) return '****'; - return `${value.slice(0, 2)}****`; -} - /** * List all team env variables from env.yaml. * @@ -50,7 +38,7 @@ export async function envList(options: GlobalOptions & { reveal?: boolean }): Pr console.log(`Team env variables (${envConfig.variables.length}):`); console.log(''); for (const v of envConfig.variables) { - const displayValue = options.reveal ? v.value : maskValue(v.value); + const displayValue = options.reveal ? v.value : maskEnvValue(v.value); console.log(` ${v.key}=${displayValue}`); if (v.description && options.verbose) { log.dim(` ${v.description}`); diff --git a/src/index.ts b/src/index.ts index a70667a..0f60fa0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,9 +70,10 @@ program program .command('list [type]') - .description('List resources (skills|rules|docs|env). For skills, --source local/all also scans installed AI agent skill directories.') + .description('List resources (skills|rules|docs|env|agents|hooks|mcp). For skills, --source local/all also scans installed AI agent skill directories.') .option('--source ', 'Where to look for skills: repo | local | all', 'all') .option('--agent ', 'Filter local agents by id (only applies to skills)') + .option('--reveal', 'Show env values in plaintext (default: masked)') .action(async (type, cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { list } = await import('./status.js'); @@ -163,7 +164,7 @@ membersCmd program .command('remove ') - .description('Remove resource(s) from team repo and all local AI tools (type: skills|rules)') + .description('Remove resource(s) from team repo and all local AI tools (type: skills|rules|agents|mcp)') .action(async (type, names) => { const globalOpts = program.opts() as GlobalOptions; const { remove } = await import('./remove.js'); diff --git a/src/remove.ts b/src/remove.ts index 180c635..a9583cd 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -7,7 +7,7 @@ import { getHandler } from './resources/index.js'; import type { GlobalOptions, ResourceType } from './types.js'; import { askConfirmation } from './utils/prompt.js'; -const REMOVABLE_TYPES: ResourceType[] = ['skills', 'rules', 'agents']; +const REMOVABLE_TYPES: ResourceType[] = ['skills', 'rules', 'agents', 'mcp']; export async function remove( type: string, diff --git a/src/resources/base.ts b/src/resources/base.ts index 98c7740..2a3abb5 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -6,7 +6,7 @@ const TOMBSTONE_FILE = '.removed'; /** * Abstract base class for resource handlers. - * Each resource type (skills, rules, docs, env) implements this. + * Each resource type (skills, rules, docs, env, agents, hooks, mcp) implements this. */ export abstract class ResourceHandler { abstract readonly type: ResourceType; diff --git a/src/resources/env.ts b/src/resources/env.ts index b268635..c64dd59 100644 --- a/src/resources/env.ts +++ b/src/resources/env.ts @@ -22,6 +22,15 @@ const EnvYamlSchema = z.object({ export type EnvVariable = z.infer; export type EnvYaml = z.infer; +/** + * Mask an env variable value for display. + * Shows first 2 chars + "****", or "****" for very short values. + */ +export function maskEnvValue(value: string): string { + if (value.length < 4) return '****'; + return `${value.slice(0, 2)}****`; +} + /** * Quote a string so it is safe to interpolate into a POSIX shell (bash/zsh/sh). * Wraps the value in single quotes and encodes any embedded single quote as diff --git a/src/status.ts b/src/status.ts index 095fdf2..4560d27 100644 --- a/src/status.ts +++ b/src/status.ts @@ -16,7 +16,10 @@ import { truncate, type AgentSkillsView, } from './agent-skills.js'; -import type { GlobalOptions, ResourceType } from './types.js'; +import { RESOURCE_TYPES, type GlobalOptions, type ResourceType } from './types.js'; +import { maskEnvValue } from './resources/env.js'; +import { parseTeamMcpServers } from './resources/mcp.js'; +import { parseHooksYaml } from './resources/hooks.js'; export interface ListOptions extends GlobalOptions { /** Where to look for resources: 'repo' (default for backwards compat), @@ -24,6 +27,8 @@ export interface ListOptions extends GlobalOptions { source?: 'repo' | 'local' | 'all'; /** Restrict --source local|all output to a single agent id. */ agent?: string; + /** Show env values in plaintext (default: masked). Same as `teamai env list --reveal`. */ + reveal?: boolean; } export async function status(options: GlobalOptions): Promise { @@ -61,27 +66,23 @@ export async function status(options: GlobalOptions): Promise { console.log(` last push: ${state.lastPush ?? 'never'}`); console.log(` last pull: ${state.lastPull ?? 'never'}`); - // Resource counts + // Resource counts — cover every ResourceType, in RESOURCE_TYPES order. console.log(''); log.info('Team resources:'); const repoPath = localConfig.repo.localPath; const counts: Record = {}; - // Skills const skillsDirs = await listDirs(path.join(repoPath, 'skills')); counts.skills = skillsDirs.length; - // Rules const rulesFiles = (await listFiles(path.join(repoPath, 'rules'))).filter(f => f.endsWith('.md')); counts.rules = rulesFiles.length; - // Docs const docsExists = await pathExists(path.join(repoPath, 'docs')); const docFiles = docsExists ? (await listFiles(path.join(repoPath, 'docs'))).filter(f => !f.startsWith('.')) : []; counts.docs = docFiles.length; - // Env const envYamlPath = path.join(repoPath, 'env', 'env.yaml'); let envCount = 0; if (await pathExists(envYamlPath)) { @@ -97,14 +98,20 @@ export async function status(options: GlobalOptions): Promise { } counts.env = envCount; - // Hooks (team-declared, hooks/hooks.yaml) + const agentsHandler = getAllHandlers().find((h) => h.type === 'agents'); + counts.agents = agentsHandler + ? (await agentsHandler.scanTeamForPull(teamConfig, localConfig)).length + : 0; + const hooksHandler = getAllHandlers().find((h) => h.type === 'hooks') as | { countHooks: (repoPath: string) => Promise } | undefined; counts.hooks = hooksHandler ? await hooksHandler.countHooks(repoPath) : 0; - for (const [type, count] of Object.entries(counts)) { - console.log(` ${type}: ${count}`); + counts.mcp = (await parseTeamMcpServers(repoPath)).length; + + for (const type of RESOURCE_TYPES) { + console.log(` ${type}: ${counts[type] ?? 0}`); } // Local pushable items @@ -162,9 +169,15 @@ export async function list(type: string | undefined, options: ListOptions): Prom return; } + if (type && !RESOURCE_TYPES.includes(type as ResourceType)) { + log.error(`Unknown resource type: ${type}. Supported: ${RESOURCE_TYPES.join(', ')}`); + process.exitCode = 1; + return; + } + const types: ResourceType[] = type ? [type as ResourceType] - : ['skills', 'rules', 'docs', 'env']; + : [...RESOURCE_TYPES]; // ── Repo section ──────────────────────────────────── if (source === 'repo' || source === 'all') { @@ -200,8 +213,12 @@ async function printRepoSection( try { const envData = YAML.parse(envContent) as { variables?: Array<{ key: string; value: string; description?: string }> }; if (envData?.variables && envData.variables.length > 0) { + if (options.reveal) { + process.stderr.write('[warn] Env values will be shown in plaintext\n'); + } for (const v of envData.variables) { - console.log(` ${v.key}=${v.value}`); + const display = options.reveal ? v.value : maskEnvValue(v.value); + console.log(` ${v.key}=${display}`); if (options.verbose && v.description) { console.log(` ${v.description}`); } @@ -221,6 +238,40 @@ async function printRepoSection( return; } + if (t === 'mcp') { + const servers = await parseTeamMcpServers(repoPath); + if (servers.length === 0) { + console.log(' (none)'); + return; + } + for (const s of servers) { + const endpoint = s.transport === 'stdio' + ? `${s.command ?? ''} ${(s.args ?? []).join(' ')}`.trim() + : (s.url ?? ''); + console.log(` ${s.name} [${s.transport}] ${endpoint}`); + if (options.verbose && s.description) { + console.log(` ${s.description}`); + } + } + return; + } + + if (t === 'hooks') { + const parsed = await parseHooksYaml(repoPath); + const hooks = parsed?.hooks ?? []; + if (hooks.length === 0) { + console.log(' (none)'); + return; + } + for (const h of hooks) { + console.log(` ${h.id} [${h.event}]`); + if (options.verbose && h.description) { + console.log(` ${h.description}`); + } + } + return; + } + const handler = getAllHandlers().find((h) => h.type === t); if (!handler) return;