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
14 changes: 8 additions & 6 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,14 @@ teamai init --http https://your-team-host/api --token <api-key>
```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
```

Expand Down
14 changes: 8 additions & 6 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,12 +200,14 @@ teamai init --http https://your-team-host/api --token <api-key>
```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 的来源 / 贡献者 / 安装位置 / 描述摘要
```

Expand Down
158 changes: 158 additions & 0 deletions src/__tests__/status-list.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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/);
});
});
16 changes: 2 additions & 14 deletions src/env-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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}`);
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <src>', 'Where to look for skills: repo | local | all', 'all')
.option('--agent <name>', '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');
Expand Down Expand Up @@ -163,7 +164,7 @@ membersCmd

program
.command('remove <type> <names...>')
.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');
Expand Down
2 changes: 1 addition & 1 deletion src/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/resources/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/resources/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ const EnvYamlSchema = z.object({
export type EnvVariable = z.infer<typeof EnvVariableSchema>;
export type EnvYaml = z.infer<typeof EnvYamlSchema>;

/**
* 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
Expand Down
Loading
Loading