mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
Initial release: tad v0.1.0
Team AI DevKit CLI for sharing skills, rules, docs, and hooks across team members via TGit.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
@@ -0,0 +1,80 @@
|
||||
# tad — Team AI DevKit
|
||||
|
||||
团队 AI 经验共享框架。自动在团队成员之间同步 skills、rules、docs、hooks 等 AI 工具配置。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 一行安装
|
||||
git clone git@git.woa.com:jeffyxu/tad-cli.git ~/.tad/tad-cli \
|
||||
&& cd ~/.tad/tad-cli && npm install && npm run build && npm link
|
||||
```
|
||||
|
||||
## 前置条件
|
||||
|
||||
设置 TGit Personal Access Token(需要 `api` 权限):
|
||||
|
||||
```bash
|
||||
# 获取 token: https://git.woa.com/profile/personal_access_tokens
|
||||
echo 'export TGIT_TOKEN=your_token_here' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 初始化(关联团队仓库、注册成员、注入 hooks)
|
||||
tad init --repo git@git.woa.com:jeffyxu/tad-team.git
|
||||
|
||||
# 2. 拉取团队资源
|
||||
tad pull
|
||||
|
||||
# 3. 推送本地新 skills 到团队
|
||||
tad push
|
||||
|
||||
# 4. 查看状态
|
||||
tad status
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `tad init` | 初始化(TGit 认证、关联仓库、注册成员、注入 hooks) |
|
||||
| `tad push [--all]` | 推送本地新资源到团队仓库 |
|
||||
| `tad pull [--silent]` | 拉取团队资源并注入到本地 AI 工具 |
|
||||
| `tad sync` | 双向同步(push + pull) |
|
||||
| `tad status` | 查看本地 vs 团队仓库差异 |
|
||||
| `tad list [type]` | 列出资源(skills\|rules\|hooks\|docs\|instincts) |
|
||||
| `tad members` | 列出团队成员 |
|
||||
| `tad doctor` | 诊断配置问题 |
|
||||
|
||||
全局选项:
|
||||
- `--dry-run` — 预览模式,不做实际变更
|
||||
- `--verbose, -v` — 详细输出
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
成员 A 成员 B
|
||||
创建 skill / 写规则 同上
|
||||
│ │
|
||||
▼ ▼
|
||||
tad push tad push
|
||||
│ │
|
||||
└──────► TGit 团队仓库 ◄──────────────┘
|
||||
│
|
||||
▼ SessionStart hook → tad pull --silent
|
||||
自动拉取到所有成员本地
|
||||
```
|
||||
|
||||
- `tad init` 会自动注入 SessionStart hook,每次启动 AI 工具会话时自动拉取团队最新内容
|
||||
- Skills 同步到 `~/.claude/skills/`、`~/.codex/skills/`、`~/.claude-internal/skills/`、`~/.cursor/skills-cursor/`
|
||||
- Rules 合并到 `~/.claude/CLAUDE.md`(使用标记注释管理)
|
||||
- Docs 同步到 `~/.tad/docs/`
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
cd ~/.tad/tad-cli && git pull && npm install && npm run build
|
||||
```
|
||||
Generated
+1979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "tad-cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Team AI DevKit — 团队 AI 经验共享框架",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"tad": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@git.woa.com:jeffyxu/tad-cli.git"
|
||||
},
|
||||
"keywords": ["ai", "team", "devkit", "skills", "sharing"],
|
||||
"author": "jeffyxu",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ora": "^8.1.0",
|
||||
"simple-git": "^3.27.0",
|
||||
"yaml": "^2.6.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^20.17.0",
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import YAML from 'yaml';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
TadConfigSchema,
|
||||
LocalConfigSchema,
|
||||
StateSchema,
|
||||
TAD_CONFIG_PATH,
|
||||
TAD_STATE_PATH,
|
||||
type TadConfig,
|
||||
type LocalConfig,
|
||||
type State,
|
||||
} from './types.js';
|
||||
import { readFileSafe, readJson, writeFile, writeJson, expandHome } from './utils/fs.js';
|
||||
import { log } from './utils/logger.js';
|
||||
|
||||
/**
|
||||
* Load the team config (tad.yaml) from the team repo
|
||||
*/
|
||||
export async function loadTeamConfig(repoPath: string): Promise<TadConfig | null> {
|
||||
const content = await readFileSafe(path.join(repoPath, 'tad.yaml'));
|
||||
if (!content) {
|
||||
log.debug('tad.yaml not found in repo');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = YAML.parse(content);
|
||||
return TadConfigSchema.parse(raw);
|
||||
} catch (e) {
|
||||
log.error(`Invalid tad.yaml: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the local config (~/.tad/config.yaml)
|
||||
*/
|
||||
export async function loadLocalConfig(): Promise<LocalConfig | null> {
|
||||
const content = await readFileSafe(expandHome(TAD_CONFIG_PATH));
|
||||
if (!content) return null;
|
||||
try {
|
||||
const raw = YAML.parse(content);
|
||||
return LocalConfigSchema.parse(raw);
|
||||
} catch (e) {
|
||||
log.debug(`Invalid local config: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the local config
|
||||
*/
|
||||
export async function saveLocalConfig(config: LocalConfig): Promise<void> {
|
||||
await writeFile(expandHome(TAD_CONFIG_PATH), YAML.stringify(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the local state (~/.tad/state.json)
|
||||
*/
|
||||
export async function loadState(): Promise<State> {
|
||||
const raw = await readJson<Record<string, unknown>>(expandHome(TAD_STATE_PATH));
|
||||
if (!raw) return StateSchema.parse({});
|
||||
return StateSchema.parse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the local state
|
||||
*/
|
||||
export async function saveState(state: State): Promise<void> {
|
||||
await writeJson(expandHome(TAD_STATE_PATH), state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that tad is initialized (local config exists)
|
||||
*/
|
||||
export async function requireInit(): Promise<{ localConfig: LocalConfig; teamConfig: TadConfig }> {
|
||||
const localConfig = await loadLocalConfig();
|
||||
if (!localConfig) {
|
||||
throw new Error('tad is not initialized. Run `tad init` first.');
|
||||
}
|
||||
const teamConfig = await loadTeamConfig(localConfig.repo.localPath);
|
||||
if (!teamConfig) {
|
||||
throw new Error('Team config (tad.yaml) not found. Check your repo path.');
|
||||
}
|
||||
return { localConfig, teamConfig };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import path from 'node:path';
|
||||
import { loadLocalConfig, loadTeamConfig } from './config.js';
|
||||
import { pathExists } from './utils/fs.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import type { GlobalOptions } from './types.js';
|
||||
|
||||
interface Check {
|
||||
name: string;
|
||||
check: () => Promise<boolean>;
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
export async function doctor(options: GlobalOptions): Promise<void> {
|
||||
log.info('Running diagnostics...\n');
|
||||
const localConfig = await loadLocalConfig();
|
||||
|
||||
const checks: Check[] = [
|
||||
{
|
||||
name: 'TGIT_TOKEN is set',
|
||||
check: async () => !!process.env.TGIT_TOKEN,
|
||||
fix: 'Set TGIT_TOKEN env var. Get a token from https://git.woa.com/profile/personal_access_tokens',
|
||||
},
|
||||
{
|
||||
name: 'Local config exists (~/.tad/config.yaml)',
|
||||
check: async () => localConfig !== null,
|
||||
fix: 'Run `tad init` to initialize',
|
||||
},
|
||||
{
|
||||
name: 'Team repo exists locally',
|
||||
check: async () => {
|
||||
if (!localConfig) return false;
|
||||
return pathExists(localConfig.repo.localPath);
|
||||
},
|
||||
fix: 'Run `tad init` to clone the team repo',
|
||||
},
|
||||
{
|
||||
name: 'Team config (tad.yaml) is valid',
|
||||
check: async () => {
|
||||
if (!localConfig) return false;
|
||||
const config = await loadTeamConfig(localConfig.repo.localPath);
|
||||
return config !== null;
|
||||
},
|
||||
fix: 'Check tad.yaml in team repo for syntax errors',
|
||||
},
|
||||
{
|
||||
name: 'tad hook in claude settings',
|
||||
check: async () => {
|
||||
const settingsPath = path.join(process.env.HOME ?? '', '.claude', 'settings.json');
|
||||
if (!await pathExists(settingsPath)) return false;
|
||||
const { readFileSafe } = await import('./utils/fs.js');
|
||||
const content = await readFileSafe(settingsPath);
|
||||
return content?.includes('[tad]') ?? false;
|
||||
},
|
||||
fix: 'Run `tad init` to inject hooks',
|
||||
},
|
||||
];
|
||||
|
||||
let allPassed = true;
|
||||
for (const { name, check, fix } of checks) {
|
||||
const ok = await check();
|
||||
if (ok) {
|
||||
console.log(` ✔ ${name}`);
|
||||
} else {
|
||||
console.log(` ✖ ${name}`);
|
||||
if (fix) console.log(` → ${fix}`);
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (allPassed) {
|
||||
log.success('All checks passed!');
|
||||
} else {
|
||||
log.warn('Some checks failed. See suggestions above.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import path from 'node:path';
|
||||
import { readJson, writeJson, readFileSafe, expandHome } from './utils/fs.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import { TAD_HOOK_DESCRIPTION_PREFIX } from './types.js';
|
||||
|
||||
interface HookEntry {
|
||||
type: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface HookMatcher {
|
||||
matcher: string;
|
||||
hooks: HookEntry[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SettingsJson {
|
||||
hooks?: Record<string, HookMatcher[]>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const TAD_SESSION_START_HOOK: HookMatcher = {
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: 'tad pull --silent' }],
|
||||
description: `${TAD_HOOK_DESCRIPTION_PREFIX} Auto-pull team resources on session start`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Inject tad hooks into a settings.json file
|
||||
*/
|
||||
export async function injectHooks(settingsPath: string): Promise<void> {
|
||||
const expanded = expandHome(settingsPath);
|
||||
const settings: SettingsJson = (await readJson<SettingsJson>(expanded)) ?? {};
|
||||
|
||||
if (!settings.hooks) {
|
||||
settings.hooks = {};
|
||||
}
|
||||
if (!settings.hooks.SessionStart) {
|
||||
settings.hooks.SessionStart = [];
|
||||
}
|
||||
|
||||
// Check if tad hook already exists
|
||||
const existing = settings.hooks.SessionStart.find(
|
||||
(h) => h.description?.startsWith(TAD_HOOK_DESCRIPTION_PREFIX)
|
||||
);
|
||||
if (existing) {
|
||||
log.debug(`tad hook already exists in ${settingsPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.hooks.SessionStart.push(TAD_SESSION_START_HOOK);
|
||||
await writeJson(expanded, settings);
|
||||
log.success(`Injected tad hook into ${settingsPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tad hooks from a settings.json file
|
||||
*/
|
||||
export async function removeHooks(settingsPath: string): Promise<void> {
|
||||
const expanded = expandHome(settingsPath);
|
||||
const settings = await readJson<SettingsJson>(expanded);
|
||||
if (!settings?.hooks) return;
|
||||
|
||||
let changed = false;
|
||||
for (const [event, matchers] of Object.entries(settings.hooks)) {
|
||||
const filtered = matchers.filter(
|
||||
(h) => !h.description?.startsWith(TAD_HOOK_DESCRIPTION_PREFIX)
|
||||
);
|
||||
if (filtered.length !== matchers.length) {
|
||||
settings.hooks[event] = filtered;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeJson(expanded, settings);
|
||||
log.success(`Removed tad hooks from ${settingsPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject tad hooks into all AI tool settings
|
||||
*/
|
||||
export async function injectHooksToAllTools(toolPaths: Record<string, { settings?: string }>): Promise<void> {
|
||||
for (const [tool, paths] of Object.entries(toolPaths)) {
|
||||
if (paths.settings) {
|
||||
const settingsPath = path.join(process.env.HOME ?? '', paths.settings);
|
||||
try {
|
||||
await injectHooks(settingsPath);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to inject hook into ${tool}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Command } from 'commander';
|
||||
import { setVerbose, setSilent } from './utils/logger.js';
|
||||
import type { GlobalOptions } from './types.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('tad')
|
||||
.description('Team AI DevKit — 团队 AI 经验共享框架')
|
||||
.version('0.1.0')
|
||||
.option('--dry-run', 'Preview mode, no changes made')
|
||||
.option('-v, --verbose', 'Verbose output')
|
||||
.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.verbose) setVerbose(true);
|
||||
});
|
||||
|
||||
program
|
||||
.command('init')
|
||||
.description('Initialize tad (configure TGit, clone repo, register member)')
|
||||
.option('--repo <url>', 'Team repo URL')
|
||||
.action(async (cmdOpts) => {
|
||||
const globalOpts = program.opts() as GlobalOptions;
|
||||
const { init } = await import('./init.js');
|
||||
await init({ ...globalOpts, ...cmdOpts });
|
||||
});
|
||||
|
||||
program
|
||||
.command('push')
|
||||
.description('Push local resources to team repo')
|
||||
.option('--all', 'Push all without confirmation')
|
||||
.action(async (cmdOpts) => {
|
||||
const globalOpts = program.opts() as GlobalOptions;
|
||||
const { push } = await import('./push.js');
|
||||
await push({ ...globalOpts, ...cmdOpts });
|
||||
});
|
||||
|
||||
program
|
||||
.command('pull')
|
||||
.description('Pull team resources and inject into local AI tools')
|
||||
.option('--silent', 'Silent mode (for hooks)')
|
||||
.action(async (cmdOpts) => {
|
||||
const globalOpts = program.opts() as GlobalOptions;
|
||||
if (cmdOpts.silent) setSilent(true);
|
||||
const { pull } = await import('./pull.js');
|
||||
await pull({ ...globalOpts, ...cmdOpts });
|
||||
});
|
||||
|
||||
program
|
||||
.command('sync')
|
||||
.description('Bidirectional sync (push + pull)')
|
||||
.action(async () => {
|
||||
const globalOpts = program.opts() as GlobalOptions;
|
||||
const { sync } = await import('./sync.js');
|
||||
await sync(globalOpts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show local vs team repo diff')
|
||||
.action(async () => {
|
||||
const globalOpts = program.opts() as GlobalOptions;
|
||||
const { status } = await import('./status.js');
|
||||
await status(globalOpts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('list [type]')
|
||||
.description('List resources (skills|rules|hooks|docs|instincts)')
|
||||
.action(async (type) => {
|
||||
const globalOpts = program.opts() as GlobalOptions;
|
||||
const { list } = await import('./status.js');
|
||||
await list(type, globalOpts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('members')
|
||||
.description('List team members')
|
||||
.action(async () => {
|
||||
const globalOpts = program.opts() as GlobalOptions;
|
||||
const { listMembers } = await import('./members.js');
|
||||
await listMembers(globalOpts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Diagnose configuration issues')
|
||||
.action(async () => {
|
||||
const globalOpts = program.opts() as GlobalOptions;
|
||||
const { doctor } = await import('./doctor.js');
|
||||
await doctor(globalOpts);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import YAML from 'yaml';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import { saveLocalConfig, loadTeamConfig } from './config.js';
|
||||
import { injectHooksToAllTools } from './hooks.js';
|
||||
import { cloneRepo } from './utils/git.js';
|
||||
import { pushRepo } from './utils/git.js';
|
||||
import { verifyToken, getCurrentUser } from './utils/tgit-api.js';
|
||||
import { ensureDir, writeFile, pathExists, expandHome } from './utils/fs.js';
|
||||
import { log, spinner } from './utils/logger.js';
|
||||
import { TAD_HOME, type GlobalOptions, type LocalConfig } from './types.js';
|
||||
|
||||
function askQuestion(prompt: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function init(options: GlobalOptions & { repo?: string }): Promise<void> {
|
||||
log.info('Initializing tad...');
|
||||
|
||||
// Step 1: Verify TGit token
|
||||
const spin = spinner('Verifying TGit token...').start();
|
||||
let user;
|
||||
try {
|
||||
user = await verifyToken();
|
||||
spin.succeed(`Authenticated as ${user.username} (${user.name})`);
|
||||
} catch (e) {
|
||||
spin.fail((e as Error).message);
|
||||
log.info('Set TGIT_TOKEN environment variable with a personal access token from:');
|
||||
log.info('https://git.woa.com/profile/personal_access_tokens');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 2: Get repo URL
|
||||
let repoUrl = options.repo ?? '';
|
||||
if (!repoUrl) {
|
||||
repoUrl = await askQuestion('Team repo URL (e.g. git@git.woa.com:team/tad-team.git): ');
|
||||
}
|
||||
if (!repoUrl) {
|
||||
log.error('Repo URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 3: Clone or link repo
|
||||
const defaultLocalPath = path.join(process.env.HOME ?? '', '.tad', 'team-repo');
|
||||
let localPath = await askQuestion(`Local clone path [${defaultLocalPath}]: `);
|
||||
if (!localPath) localPath = defaultLocalPath;
|
||||
localPath = expandHome(localPath);
|
||||
|
||||
if (await pathExists(localPath)) {
|
||||
log.info(`Repo already exists at ${localPath}, using existing clone`);
|
||||
} else {
|
||||
const cloneSpin = spinner('Cloning team repo...').start();
|
||||
try {
|
||||
await cloneRepo(repoUrl, localPath);
|
||||
cloneSpin.succeed('Team repo cloned');
|
||||
} catch (e) {
|
||||
cloneSpin.fail(`Clone failed: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Load team config
|
||||
const teamConfig = await loadTeamConfig(localPath);
|
||||
if (!teamConfig) {
|
||||
log.warn('tad.yaml not found in repo. Creating default config...');
|
||||
const defaultConfig = YAML.stringify({
|
||||
team: 'my-team',
|
||||
description: 'Team AI DevKit shared resources',
|
||||
repo: repoUrl,
|
||||
sharing: {
|
||||
skills: { syncTargets: ['claude', 'codex', 'claude-internal', 'cursor'] },
|
||||
rules: { enforced: [] },
|
||||
docs: { localDir: '~/.tad/docs' },
|
||||
},
|
||||
});
|
||||
await writeFile(path.join(localPath, 'tad.yaml'), defaultConfig);
|
||||
|
||||
// Create standard directories
|
||||
for (const dir of ['members', 'skills', 'rules', 'docs', 'hooks', 'hooks/scripts', 'instincts']) {
|
||||
await ensureDir(path.join(localPath, dir));
|
||||
// create .gitkeep in empty dirs
|
||||
const gitkeep = path.join(localPath, dir, '.gitkeep');
|
||||
if (!await pathExists(gitkeep)) {
|
||||
await writeFile(gitkeep, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Create member file
|
||||
const memberPath = path.join(localPath, 'members', `${user.username}.yaml`);
|
||||
if (!await pathExists(memberPath)) {
|
||||
const memberYaml = YAML.stringify({
|
||||
username: user.username,
|
||||
displayName: user.name || user.username,
|
||||
registeredAt: new Date().toISOString(),
|
||||
});
|
||||
await writeFile(memberPath, memberYaml);
|
||||
log.success(`Registered as team member: ${user.username}`);
|
||||
|
||||
if (!options.dryRun) {
|
||||
try {
|
||||
await pushRepo(localPath, `[tad] Register member: ${user.username}`, [
|
||||
'members/',
|
||||
'tad.yaml',
|
||||
'skills/.gitkeep',
|
||||
'rules/.gitkeep',
|
||||
'docs/.gitkeep',
|
||||
'hooks/.gitkeep',
|
||||
'hooks/scripts/.gitkeep',
|
||||
'instincts/.gitkeep',
|
||||
]);
|
||||
log.success('Member registration pushed to team repo');
|
||||
} catch (e) {
|
||||
log.warn(`Push failed (you can push manually later): ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info(`Member ${user.username} already registered`);
|
||||
}
|
||||
|
||||
// Step 6: Save local config
|
||||
const localConfig: LocalConfig = {
|
||||
repo: { localPath, remote: repoUrl },
|
||||
username: user.username,
|
||||
};
|
||||
await ensureDir(TAD_HOME);
|
||||
await saveLocalConfig(localConfig);
|
||||
log.success(`Local config saved to ${TAD_HOME}/config.yaml`);
|
||||
|
||||
// Step 7: Inject hooks into AI tools
|
||||
const reloadedTeamConfig = await loadTeamConfig(localPath);
|
||||
if (reloadedTeamConfig) {
|
||||
await injectHooksToAllTools(reloadedTeamConfig.toolPaths);
|
||||
}
|
||||
|
||||
log.success('tad initialized successfully!');
|
||||
log.info('Run `tad pull` to sync team resources, or `tad status` to check.');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import YAML from 'yaml';
|
||||
import path from 'node:path';
|
||||
import { requireInit } from './config.js';
|
||||
import { readFileSafe, listFiles } from './utils/fs.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import type { GlobalOptions, MemberConfig } from './types.js';
|
||||
|
||||
export async function listMembers(options: GlobalOptions): Promise<void> {
|
||||
const { localConfig } = await requireInit();
|
||||
const membersDir = path.join(localConfig.repo.localPath, 'members');
|
||||
const files = await listFiles(membersDir);
|
||||
const yamlFiles = files.filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
|
||||
|
||||
if (yamlFiles.length === 0) {
|
||||
log.info('No team members registered');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Team members (${yamlFiles.length}):`);
|
||||
console.log('');
|
||||
|
||||
for (const file of yamlFiles) {
|
||||
const content = await readFileSafe(path.join(membersDir, file));
|
||||
if (!content) continue;
|
||||
try {
|
||||
const member = YAML.parse(content) as MemberConfig;
|
||||
const isSelf = member.username === localConfig.username;
|
||||
const marker = isSelf ? ' (you)' : '';
|
||||
const display = member.displayName ? ` — ${member.displayName}` : '';
|
||||
console.log(` ${member.username}${display}${marker}`);
|
||||
if (options.verbose) {
|
||||
console.log(` registered: ${member.registeredAt}`);
|
||||
}
|
||||
} catch {
|
||||
log.warn(`Invalid member file: ${file}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { requireInit, loadState, saveState } from './config.js';
|
||||
import { pullRepo } from './utils/git.js';
|
||||
import { log, spinner } from './utils/logger.js';
|
||||
import { getHandler, RulesHandler } from './resources/index.js';
|
||||
import type { GlobalOptions, ResourceType } from './types.js';
|
||||
|
||||
export async function pull(options: GlobalOptions): Promise<void> {
|
||||
const { localConfig, teamConfig } = await requireInit();
|
||||
|
||||
// Step 1: git pull
|
||||
const pullSpin = spinner('Pulling team repo...').start();
|
||||
try {
|
||||
const result = await pullRepo(localConfig.repo.localPath);
|
||||
pullSpin.succeed(`Team repo: ${result}`);
|
||||
} catch (e) {
|
||||
pullSpin.fail(`Pull failed: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload team config after pull (might have changed)
|
||||
const { teamConfig: freshConfig } = await requireInit();
|
||||
|
||||
// Step 2: Sync each resource type
|
||||
const resourceTypes: ResourceType[] = ['skills', 'rules', 'hooks', 'docs', 'instincts'];
|
||||
let totalSynced = 0;
|
||||
|
||||
for (const type of resourceTypes) {
|
||||
const handler = getHandler(type);
|
||||
|
||||
if (type === 'rules') {
|
||||
// Rules use bulk merge into CLAUDE.md
|
||||
const rulesHandler = handler as RulesHandler;
|
||||
const items = await rulesHandler.scanTeamForPull(freshConfig, localConfig);
|
||||
if (items.length > 0) {
|
||||
if (options.dryRun) {
|
||||
log.info(`[dry-run] Would merge ${items.length} rule(s) into CLAUDE.md`);
|
||||
} else {
|
||||
await rulesHandler.pullAllRules(freshConfig, localConfig);
|
||||
log.success(`Merged ${items.length} rule(s) into CLAUDE.md`);
|
||||
}
|
||||
totalSynced += items.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const items = await handler.scanTeamForPull(freshConfig, localConfig);
|
||||
if (items.length === 0) continue;
|
||||
|
||||
if (options.dryRun) {
|
||||
log.info(`[dry-run] Would pull ${items.length} ${type}`);
|
||||
for (const item of items) {
|
||||
log.dim(` ${item.name}`);
|
||||
}
|
||||
} else {
|
||||
for (const item of items) {
|
||||
await handler.pullItem(item, freshConfig, localConfig);
|
||||
}
|
||||
log.success(`Synced ${items.length} ${type}`);
|
||||
}
|
||||
|
||||
totalSynced += items.length;
|
||||
}
|
||||
|
||||
if (totalSynced === 0) {
|
||||
log.info('No resources to sync');
|
||||
} else if (!options.dryRun) {
|
||||
// Update state
|
||||
const state = await loadState();
|
||||
state.lastPull = new Date().toISOString();
|
||||
await saveState(state);
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import readline from 'node:readline';
|
||||
import { requireInit, loadState, saveState } from './config.js';
|
||||
import { pushRepo } from './utils/git.js';
|
||||
import { log, spinner } from './utils/logger.js';
|
||||
import { getHandler } from './resources/index.js';
|
||||
import type { GlobalOptions, ResourceItem, ResourceType } from './types.js';
|
||||
|
||||
function askConfirm(prompt: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${prompt} [Y/n] `, (answer) => {
|
||||
rl.close();
|
||||
resolve(!answer || answer.toLowerCase() === 'y');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function push(options: GlobalOptions & { all?: boolean }): Promise<void> {
|
||||
const { localConfig, teamConfig } = await requireInit();
|
||||
|
||||
const spin = spinner('Scanning local resources...').start();
|
||||
|
||||
// Scan for pushable resources
|
||||
const pushableTypes: ResourceType[] = ['skills', 'instincts'];
|
||||
const allItems: ResourceItem[] = [];
|
||||
|
||||
for (const type of pushableTypes) {
|
||||
const handler = getHandler(type);
|
||||
const items = await handler.scanLocalForPush(teamConfig, localConfig);
|
||||
allItems.push(...items);
|
||||
}
|
||||
|
||||
spin.stop();
|
||||
|
||||
if (allItems.length === 0) {
|
||||
log.info('No new resources to push');
|
||||
return;
|
||||
}
|
||||
|
||||
// Display items
|
||||
console.log('');
|
||||
console.log(`Found ${allItems.length} new resource(s) to push:`);
|
||||
console.log('');
|
||||
for (const item of allItems) {
|
||||
console.log(` [${item.type}] ${item.name}`);
|
||||
if (options.verbose) {
|
||||
console.log(` from: ${item.sourcePath}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
if (options.dryRun) {
|
||||
log.info('Dry run — no changes made');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm
|
||||
if (!options.all && !options.silent) {
|
||||
const confirmed = await askConfirm('Push these resources to team repo?');
|
||||
if (!confirmed) {
|
||||
log.info('Cancelled');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Push each item
|
||||
const pushSpin = spinner('Pushing resources...').start();
|
||||
const pushedFiles: string[] = [];
|
||||
|
||||
for (const item of allItems) {
|
||||
const handler = getHandler(item.type);
|
||||
await handler.pushItem(item, teamConfig, localConfig);
|
||||
pushedFiles.push(item.relativePath);
|
||||
}
|
||||
|
||||
// Git commit and push
|
||||
try {
|
||||
// Add all files under the resource directories
|
||||
const gitFiles = ['skills/', 'instincts/'];
|
||||
await pushRepo(
|
||||
localConfig.repo.localPath,
|
||||
`[tad] Push ${allItems.length} resource(s) from ${localConfig.username}`,
|
||||
gitFiles,
|
||||
);
|
||||
pushSpin.succeed(`Pushed ${allItems.length} resource(s) to team repo`);
|
||||
} catch (e) {
|
||||
pushSpin.fail(`Push failed: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update state
|
||||
const state = await loadState();
|
||||
state.lastPush = new Date().toISOString();
|
||||
for (const item of allItems) {
|
||||
if (item.type === 'skills' && !state.pushedSkills.includes(item.name)) {
|
||||
state.pushedSkills.push(item.name);
|
||||
}
|
||||
if (item.type === 'instincts' && !state.pushedInstincts.includes(item.name)) {
|
||||
state.pushedInstincts.push(item.name);
|
||||
}
|
||||
}
|
||||
await saveState(state);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ResourceType, ResourceItem, ResourceDiff, TadConfig, LocalConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for resource handlers.
|
||||
* Each resource type (skills, rules, hooks, docs, instincts) implements this.
|
||||
*/
|
||||
export abstract class ResourceHandler {
|
||||
abstract readonly type: ResourceType;
|
||||
|
||||
/**
|
||||
* Scan local sources for items that could be pushed to the team repo.
|
||||
* Returns items found locally that are not yet in the team repo.
|
||||
*/
|
||||
abstract scanLocalForPush(
|
||||
teamConfig: TadConfig,
|
||||
localConfig: LocalConfig,
|
||||
): Promise<ResourceItem[]>;
|
||||
|
||||
/**
|
||||
* Scan team repo for items that should be pulled to local.
|
||||
* Returns items from the team repo.
|
||||
*/
|
||||
abstract scanTeamForPull(
|
||||
teamConfig: TadConfig,
|
||||
localConfig: LocalConfig,
|
||||
): Promise<ResourceItem[]>;
|
||||
|
||||
/**
|
||||
* Copy a resource item from local to the team repo directory.
|
||||
*/
|
||||
abstract pushItem(
|
||||
item: ResourceItem,
|
||||
teamConfig: TadConfig,
|
||||
localConfig: LocalConfig,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Pull a resource item from the team repo and inject into local AI tool directories.
|
||||
*/
|
||||
abstract pullItem(
|
||||
item: ResourceItem,
|
||||
teamConfig: TadConfig,
|
||||
localConfig: LocalConfig,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Compute diff between local and team repo for this resource type.
|
||||
*/
|
||||
async diff(
|
||||
teamConfig: TadConfig,
|
||||
localConfig: LocalConfig,
|
||||
): Promise<ResourceDiff> {
|
||||
const localItems = await this.scanLocalForPush(teamConfig, localConfig);
|
||||
const teamItems = await this.scanTeamForPull(teamConfig, localConfig);
|
||||
|
||||
const teamNames = new Set(teamItems.map((i) => i.name));
|
||||
const localNames = new Set(localItems.map((i) => i.name));
|
||||
|
||||
const added = localItems.filter((i) => !teamNames.has(i.name));
|
||||
const removed = teamItems.filter((i) => !localNames.has(i.name));
|
||||
|
||||
return { added, modified: [], removed };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import path from 'node:path';
|
||||
import fse from 'fs-extra';
|
||||
import { ResourceHandler } from './base.js';
|
||||
import type { ResourceItem, TadConfig, LocalConfig } from '../types.js';
|
||||
import { pathExists, copyDir, expandHome } from '../utils/fs.js';
|
||||
import { log } from '../utils/logger.js';
|
||||
|
||||
export class DocsHandler extends ResourceHandler {
|
||||
readonly type = 'docs' as const;
|
||||
|
||||
async scanLocalForPush(_teamConfig: TadConfig, _localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
// Docs are managed directly in team repo
|
||||
return [];
|
||||
}
|
||||
|
||||
async scanTeamForPull(_teamConfig: TadConfig, localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
const docsDir = path.join(localConfig.repo.localPath, 'docs');
|
||||
if (!await pathExists(docsDir)) return [];
|
||||
|
||||
return [{
|
||||
name: 'docs',
|
||||
type: 'docs',
|
||||
sourcePath: docsDir,
|
||||
relativePath: 'docs/',
|
||||
}];
|
||||
}
|
||||
|
||||
async pushItem(_item: ResourceItem, _teamConfig: TadConfig, _localConfig: LocalConfig): Promise<void> {
|
||||
// No-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync docs from team repo to local docs directory.
|
||||
*/
|
||||
async pullItem(item: ResourceItem, teamConfig: TadConfig, _localConfig: LocalConfig): Promise<void> {
|
||||
const localDocsDir = expandHome(teamConfig.sharing.docs.localDir);
|
||||
try {
|
||||
await copyDir(item.sourcePath, localDocsDir);
|
||||
log.debug(`Synced docs → ${localDocsDir}`);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to sync docs: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import path from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import { ResourceHandler } from './base.js';
|
||||
import type { ResourceItem, TadConfig, LocalConfig } from '../types.js';
|
||||
import { pathExists, readFileSafe, readJson, writeJson } from '../utils/fs.js';
|
||||
import { log } from '../utils/logger.js';
|
||||
import { TAD_HOOK_DESCRIPTION_PREFIX } from '../types.js';
|
||||
|
||||
interface HookEntry {
|
||||
type: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface HookMatcher {
|
||||
matcher: string;
|
||||
hooks: HookEntry[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface HooksYaml {
|
||||
hooks: Record<string, HookMatcher[]>;
|
||||
}
|
||||
|
||||
interface SettingsJson {
|
||||
hooks?: Record<string, HookMatcher[]>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class HooksConfigHandler extends ResourceHandler {
|
||||
readonly type = 'hooks' as const;
|
||||
|
||||
async scanLocalForPush(_teamConfig: TadConfig, _localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
// Hooks are managed directly in team repo
|
||||
return [];
|
||||
}
|
||||
|
||||
async scanTeamForPull(_teamConfig: TadConfig, localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
const hooksYamlPath = path.join(localConfig.repo.localPath, 'hooks', 'hooks.yaml');
|
||||
if (!await pathExists(hooksYamlPath)) return [];
|
||||
|
||||
return [{
|
||||
name: 'hooks.yaml',
|
||||
type: 'hooks',
|
||||
sourcePath: hooksYamlPath,
|
||||
relativePath: 'hooks/hooks.yaml',
|
||||
}];
|
||||
}
|
||||
|
||||
async pushItem(_item: ResourceItem, _teamConfig: TadConfig, _localConfig: LocalConfig): Promise<void> {
|
||||
// No-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge team hooks from hooks.yaml into each AI tool's settings.json
|
||||
*/
|
||||
async pullItem(item: ResourceItem, teamConfig: TadConfig, _localConfig: LocalConfig): Promise<void> {
|
||||
const content = await readFileSafe(item.sourcePath);
|
||||
if (!content) return;
|
||||
|
||||
let hooksConfig: HooksYaml;
|
||||
try {
|
||||
hooksConfig = YAML.parse(content) as HooksYaml;
|
||||
} catch {
|
||||
log.warn('Invalid hooks.yaml format');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hooksConfig?.hooks) return;
|
||||
|
||||
for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) {
|
||||
if (!toolPath.settings) continue;
|
||||
|
||||
const settingsPath = path.join(process.env.HOME ?? '', toolPath.settings);
|
||||
const settings: SettingsJson = (await readJson<SettingsJson>(settingsPath)) ?? {};
|
||||
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
|
||||
let changed = false;
|
||||
|
||||
for (const [event, matchers] of Object.entries(hooksConfig.hooks)) {
|
||||
if (!settings.hooks[event]) settings.hooks[event] = [];
|
||||
|
||||
for (const matcher of matchers) {
|
||||
// Tag with tad prefix for identification
|
||||
const description = matcher.description
|
||||
? `${TAD_HOOK_DESCRIPTION_PREFIX} ${matcher.description}`
|
||||
: `${TAD_HOOK_DESCRIPTION_PREFIX} Team hook`;
|
||||
|
||||
// Check if this exact hook already exists
|
||||
const exists = settings.hooks[event].some(
|
||||
(h) => h.description === description
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
settings.hooks[event].push({ ...matcher, description });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
try {
|
||||
await writeJson(settingsPath, settings);
|
||||
log.debug(`Updated hooks in ${tool} settings.json`);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to update ${tool} settings: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ResourceHandler } from './base.js';
|
||||
import { SkillsHandler } from './skills.js';
|
||||
import { InstinctsHandler } from './instincts.js';
|
||||
import { RulesHandler } from './rules.js';
|
||||
import { HooksConfigHandler } from './hooks-config.js';
|
||||
import { DocsHandler } from './docs.js';
|
||||
import type { ResourceType } from '../types.js';
|
||||
|
||||
const handlers: Record<ResourceType, ResourceHandler> = {
|
||||
skills: new SkillsHandler(),
|
||||
instincts: new InstinctsHandler(),
|
||||
rules: new RulesHandler(),
|
||||
hooks: new HooksConfigHandler(),
|
||||
docs: new DocsHandler(),
|
||||
};
|
||||
|
||||
export function getHandler(type: ResourceType): ResourceHandler {
|
||||
return handlers[type];
|
||||
}
|
||||
|
||||
export function getAllHandlers(): ResourceHandler[] {
|
||||
return Object.values(handlers);
|
||||
}
|
||||
|
||||
export { SkillsHandler, InstinctsHandler, RulesHandler, HooksConfigHandler, DocsHandler };
|
||||
@@ -0,0 +1,124 @@
|
||||
import path from 'node:path';
|
||||
import { ResourceHandler } from './base.js';
|
||||
import type { ResourceItem, TadConfig, LocalConfig } from '../types.js';
|
||||
import { listDirs, listFiles, pathExists, copyDir, copyFile, expandHome } from '../utils/fs.js';
|
||||
import { log } from '../utils/logger.js';
|
||||
|
||||
const HOMUNCULUS_DIR = '.claude/homunculus';
|
||||
|
||||
export class InstinctsHandler extends ResourceHandler {
|
||||
readonly type = 'instincts' as const;
|
||||
|
||||
/**
|
||||
* Scan CL-v2 homunculus instincts for items to push.
|
||||
*/
|
||||
async scanLocalForPush(teamConfig: TadConfig, localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
const home = process.env.HOME ?? '';
|
||||
const instinctsDir = path.join(home, HOMUNCULUS_DIR, 'instincts', 'personal');
|
||||
const teamInstinctsDir = path.join(localConfig.repo.localPath, 'instincts', localConfig.username);
|
||||
|
||||
const items: ResourceItem[] = [];
|
||||
|
||||
if (!await pathExists(instinctsDir)) return items;
|
||||
|
||||
const files = await listFiles(instinctsDir);
|
||||
const teamFiles = new Set(await pathExists(teamInstinctsDir) ? await listFiles(teamInstinctsDir) : []);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.yaml') && !file.endsWith('.yml') && !file.endsWith('.md')) continue;
|
||||
if (teamFiles.has(file)) continue;
|
||||
|
||||
items.push({
|
||||
name: file.replace(/\.(yaml|yml|md)$/, ''),
|
||||
type: 'instincts',
|
||||
sourcePath: path.join(instinctsDir, file),
|
||||
relativePath: `instincts/${localConfig.username}/${file}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Also scan project-scoped instincts
|
||||
const projectsDir = path.join(home, HOMUNCULUS_DIR, 'projects');
|
||||
if (await pathExists(projectsDir)) {
|
||||
const projectHashes = await listDirs(projectsDir);
|
||||
for (const hash of projectHashes) {
|
||||
const projectInstincts = path.join(projectsDir, hash, 'instincts', 'personal');
|
||||
if (!await pathExists(projectInstincts)) continue;
|
||||
|
||||
const pFiles = await listFiles(projectInstincts);
|
||||
for (const file of pFiles) {
|
||||
if (!file.endsWith('.yaml') && !file.endsWith('.yml') && !file.endsWith('.md')) continue;
|
||||
const name = `${hash}/${file.replace(/\.(yaml|yml|md)$/, '')}`;
|
||||
if (teamFiles.has(file)) continue;
|
||||
|
||||
items.push({
|
||||
name,
|
||||
type: 'instincts',
|
||||
sourcePath: path.join(projectInstincts, file),
|
||||
relativePath: `instincts/${localConfig.username}/${file}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan team repo for instincts to pull (from all members).
|
||||
*/
|
||||
async scanTeamForPull(teamConfig: TadConfig, localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
const teamInstinctsDir = path.join(localConfig.repo.localPath, 'instincts');
|
||||
const items: ResourceItem[] = [];
|
||||
|
||||
if (!await pathExists(teamInstinctsDir)) return items;
|
||||
|
||||
const memberDirs = await listDirs(teamInstinctsDir);
|
||||
for (const member of memberDirs) {
|
||||
const memberDir = path.join(teamInstinctsDir, member);
|
||||
const files = await listFiles(memberDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.yaml') && !file.endsWith('.yml') && !file.endsWith('.md')) continue;
|
||||
items.push({
|
||||
name: `${member}/${file.replace(/\.(yaml|yml|md)$/, '')}`,
|
||||
type: 'instincts',
|
||||
sourcePath: path.join(memberDir, file),
|
||||
relativePath: `instincts/${member}/${file}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a local instinct to the team repo under the user's directory.
|
||||
*/
|
||||
async pushItem(item: ResourceItem, _teamConfig: TadConfig, localConfig: LocalConfig): Promise<void> {
|
||||
const dest = path.join(localConfig.repo.localPath, item.relativePath);
|
||||
await copyFile(item.sourcePath, dest);
|
||||
log.debug(`Copied instinct ${item.name} → team repo`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull instincts from team repo to CL-v2 inherited directory.
|
||||
*/
|
||||
async pullItem(item: ResourceItem, _teamConfig: TadConfig, _localConfig: LocalConfig): Promise<void> {
|
||||
const home = process.env.HOME ?? '';
|
||||
const inheritedDir = path.join(home, HOMUNCULUS_DIR, 'instincts', 'inherited');
|
||||
|
||||
// Only import to inherited if homunculus dir exists (CL-v2 installed)
|
||||
if (!await pathExists(path.join(home, HOMUNCULUS_DIR))) {
|
||||
log.debug('CL-v2 not installed, skipping instinct import');
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = path.basename(item.sourcePath);
|
||||
const dest = path.join(inheritedDir, filename);
|
||||
try {
|
||||
await copyFile(item.sourcePath, dest);
|
||||
log.debug(`Imported instinct ${item.name} → inherited/`);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to import instinct ${item.name}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import path from 'node:path';
|
||||
import { ResourceHandler } from './base.js';
|
||||
import type { ResourceItem, TadConfig, LocalConfig } from '../types.js';
|
||||
import { listFiles, pathExists, readFileSafe, writeFile } from '../utils/fs.js';
|
||||
import { log } from '../utils/logger.js';
|
||||
import { TAD_RULES_START, TAD_RULES_END } from '../types.js';
|
||||
|
||||
export class RulesHandler extends ResourceHandler {
|
||||
readonly type = 'rules' as const;
|
||||
|
||||
async scanLocalForPush(_teamConfig: TadConfig, _localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
// Rules are typically created directly in the team repo, not pushed from local
|
||||
return [];
|
||||
}
|
||||
|
||||
async scanTeamForPull(_teamConfig: TadConfig, localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
const rulesDir = path.join(localConfig.repo.localPath, 'rules');
|
||||
if (!await pathExists(rulesDir)) return [];
|
||||
|
||||
const files = await listFiles(rulesDir);
|
||||
return files
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => ({
|
||||
name: f.replace(/\.md$/, ''),
|
||||
type: 'rules' as const,
|
||||
sourcePath: path.join(rulesDir, f),
|
||||
relativePath: `rules/${f}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async pushItem(_item: ResourceItem, _teamConfig: TadConfig, _localConfig: LocalConfig): Promise<void> {
|
||||
// No-op: rules are managed directly in team repo
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull rules from team repo and merge into CLAUDE.md files.
|
||||
* Uses marker comments to manage tad-injected sections.
|
||||
*/
|
||||
async pullItem(item: ResourceItem, teamConfig: TadConfig, _localConfig: LocalConfig): Promise<void> {
|
||||
// This is handled in bulk by pullAllRules
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all rules into CLAUDE.md for each AI tool that has a claudemd path.
|
||||
*/
|
||||
async pullAllRules(teamConfig: TadConfig, localConfig: LocalConfig): Promise<void> {
|
||||
const rules = await this.scanTeamForPull(teamConfig, localConfig);
|
||||
if (rules.length === 0) return;
|
||||
|
||||
// Build the merged rules block
|
||||
const ruleContents: string[] = [];
|
||||
for (const rule of rules) {
|
||||
const content = await readFileSafe(rule.sourcePath);
|
||||
if (content) {
|
||||
ruleContents.push(`### ${rule.name}\n\n${content.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const rulesBlock = [
|
||||
TAD_RULES_START,
|
||||
'<!-- DO NOT EDIT: This section is auto-managed by tad -->',
|
||||
'',
|
||||
'## Team Rules (tad)',
|
||||
'',
|
||||
ruleContents.join('\n\n---\n\n'),
|
||||
'',
|
||||
TAD_RULES_END,
|
||||
].join('\n');
|
||||
|
||||
// Inject into each tool's CLAUDE.md
|
||||
for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) {
|
||||
if (!toolPath.claudemd) continue;
|
||||
|
||||
const claudeMdPath = path.join(process.env.HOME ?? '', toolPath.claudemd);
|
||||
|
||||
let existing = await readFileSafe(claudeMdPath) ?? '';
|
||||
|
||||
// Replace existing tad rules block or append
|
||||
const startIdx = existing.indexOf(TAD_RULES_START);
|
||||
const endIdx = existing.indexOf(TAD_RULES_END);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
existing = existing.substring(0, startIdx) + rulesBlock + existing.substring(endIdx + TAD_RULES_END.length);
|
||||
} else {
|
||||
existing = existing.trimEnd() + '\n\n' + rulesBlock + '\n';
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFile(claudeMdPath, existing);
|
||||
log.debug(`Updated rules in ${tool} CLAUDE.md`);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to update ${tool} CLAUDE.md: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import path from 'node:path';
|
||||
import { ResourceHandler } from './base.js';
|
||||
import type { ResourceItem, TadConfig, LocalConfig } from '../types.js';
|
||||
import { listDirs, pathExists, copyDir } from '../utils/fs.js';
|
||||
import { log } from '../utils/logger.js';
|
||||
|
||||
export class SkillsHandler extends ResourceHandler {
|
||||
readonly type = 'skills' as const;
|
||||
|
||||
/**
|
||||
* Scan local AI tool skill directories for skills not in the team repo.
|
||||
*/
|
||||
async scanLocalForPush(teamConfig: TadConfig, localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
const teamSkillsDir = path.join(localConfig.repo.localPath, 'skills');
|
||||
const teamSkills = new Set(await listDirs(teamSkillsDir));
|
||||
|
||||
const localSkills: ResourceItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Scan each tool's skills directory
|
||||
for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) {
|
||||
const skillsDir = path.join(process.env.HOME ?? '', toolPath.skills);
|
||||
if (!await pathExists(skillsDir)) continue;
|
||||
|
||||
const dirs = await listDirs(skillsDir);
|
||||
for (const dir of dirs) {
|
||||
if (seen.has(dir) || teamSkills.has(dir)) continue;
|
||||
// Check for SKILL.md to confirm it's a valid skill
|
||||
const skillMd = path.join(skillsDir, dir, 'SKILL.md');
|
||||
if (!await pathExists(skillMd)) continue;
|
||||
|
||||
seen.add(dir);
|
||||
localSkills.push({
|
||||
name: dir,
|
||||
type: 'skills',
|
||||
sourcePath: path.join(skillsDir, dir),
|
||||
relativePath: `skills/${dir}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return localSkills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan team repo for skills to pull.
|
||||
*/
|
||||
async scanTeamForPull(teamConfig: TadConfig, localConfig: LocalConfig): Promise<ResourceItem[]> {
|
||||
const teamSkillsDir = path.join(localConfig.repo.localPath, 'skills');
|
||||
const dirs = await listDirs(teamSkillsDir);
|
||||
|
||||
return dirs.map((dir) => ({
|
||||
name: dir,
|
||||
type: 'skills' as const,
|
||||
sourcePath: path.join(teamSkillsDir, dir),
|
||||
relativePath: `skills/${dir}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a local skill to the team repo.
|
||||
*/
|
||||
async pushItem(item: ResourceItem, _teamConfig: TadConfig, localConfig: LocalConfig): Promise<void> {
|
||||
const dest = path.join(localConfig.repo.localPath, 'skills', item.name);
|
||||
await copyDir(item.sourcePath, dest);
|
||||
log.debug(`Copied skill ${item.name} → team repo`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a skill from team repo to all configured AI tool directories.
|
||||
*/
|
||||
async pullItem(item: ResourceItem, teamConfig: TadConfig, _localConfig: LocalConfig): Promise<void> {
|
||||
const syncTargets = teamConfig.sharing.skills.syncTargets;
|
||||
|
||||
for (const tool of syncTargets) {
|
||||
const toolPath = teamConfig.toolPaths[tool];
|
||||
if (!toolPath) continue;
|
||||
|
||||
const dest = path.join(process.env.HOME ?? '', toolPath.skills, item.name);
|
||||
try {
|
||||
await copyDir(item.sourcePath, dest);
|
||||
log.debug(`Synced skill ${item.name} → ${tool}`);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to sync skill ${item.name} to ${tool}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import path from 'node:path';
|
||||
import { requireInit, loadState } from './config.js';
|
||||
import { getRepoStatus } from './utils/git.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import { getAllHandlers } from './resources/index.js';
|
||||
import { listDirs, listFiles, pathExists } from './utils/fs.js';
|
||||
import type { GlobalOptions, ResourceType } from './types.js';
|
||||
|
||||
export async function status(options: GlobalOptions): Promise<void> {
|
||||
const { localConfig, teamConfig } = await requireInit();
|
||||
|
||||
// Git status
|
||||
console.log('');
|
||||
log.info('Team repo status:');
|
||||
try {
|
||||
const gitStatus = await getRepoStatus(localConfig.repo.localPath);
|
||||
console.log(` repo: ${localConfig.repo.remote}`);
|
||||
console.log(` local: ${localConfig.repo.localPath}`);
|
||||
if (gitStatus.ahead > 0) console.log(` ahead: ${gitStatus.ahead} commit(s)`);
|
||||
if (gitStatus.behind > 0) console.log(` behind: ${gitStatus.behind} commit(s)`);
|
||||
if (gitStatus.modified.length > 0) {
|
||||
console.log(` modified: ${gitStatus.modified.length} file(s)`);
|
||||
}
|
||||
if (gitStatus.ahead === 0 && gitStatus.behind === 0 && gitStatus.modified.length === 0) {
|
||||
console.log(' up to date');
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn(`Could not check git status: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
// State
|
||||
const state = await loadState();
|
||||
console.log('');
|
||||
log.info('Sync state:');
|
||||
console.log(` last push: ${state.lastPush ?? 'never'}`);
|
||||
console.log(` last pull: ${state.lastPull ?? 'never'}`);
|
||||
|
||||
// Resource counts
|
||||
console.log('');
|
||||
log.info('Team resources:');
|
||||
|
||||
const repoPath = localConfig.repo.localPath;
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
// 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'));
|
||||
counts.docs = docsExists ? (await listDirs(path.join(repoPath, 'docs'))).length : 0;
|
||||
|
||||
// Instincts
|
||||
const instinctsDir = path.join(repoPath, 'instincts');
|
||||
let instinctCount = 0;
|
||||
if (await pathExists(instinctsDir)) {
|
||||
const memberDirs = await listDirs(instinctsDir);
|
||||
for (const m of memberDirs) {
|
||||
const files = await listFiles(path.join(instinctsDir, m));
|
||||
instinctCount += files.filter(f => f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.md')).length;
|
||||
}
|
||||
}
|
||||
counts.instincts = instinctCount;
|
||||
|
||||
// Hooks
|
||||
const hooksYaml = await pathExists(path.join(repoPath, 'hooks', 'hooks.yaml'));
|
||||
counts.hooks = hooksYaml ? 1 : 0;
|
||||
|
||||
for (const [type, count] of Object.entries(counts)) {
|
||||
console.log(` ${type}: ${count}`);
|
||||
}
|
||||
|
||||
// Local pushable items
|
||||
console.log('');
|
||||
log.info('Local resources not yet pushed:');
|
||||
let anyNew = false;
|
||||
for (const handler of getAllHandlers()) {
|
||||
const items = await handler.scanLocalForPush(teamConfig, localConfig);
|
||||
if (items.length > 0) {
|
||||
anyNew = true;
|
||||
console.log(` [${handler.type}] ${items.length} new`);
|
||||
if (options.verbose) {
|
||||
for (const item of items) {
|
||||
console.log(` - ${item.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!anyNew) {
|
||||
console.log(' (none)');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function list(type: string | undefined, options: GlobalOptions): Promise<void> {
|
||||
const { localConfig, teamConfig } = await requireInit();
|
||||
const repoPath = localConfig.repo.localPath;
|
||||
|
||||
const types: ResourceType[] = type
|
||||
? [type as ResourceType]
|
||||
: ['skills', 'rules', 'hooks', 'docs', 'instincts'];
|
||||
|
||||
for (const t of types) {
|
||||
console.log('');
|
||||
console.log(`=== ${t.toUpperCase()} ===`);
|
||||
|
||||
const handler = getAllHandlers().find((h) => h.type === t);
|
||||
if (!handler) continue;
|
||||
|
||||
const items = await handler.scanTeamForPull(teamConfig, localConfig);
|
||||
if (items.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const item of items) {
|
||||
console.log(` ${item.name}`);
|
||||
if (options.verbose) {
|
||||
console.log(` path: ${item.sourcePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { push } from './push.js';
|
||||
import { pull } from './pull.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import type { GlobalOptions } from './types.js';
|
||||
|
||||
export async function sync(options: GlobalOptions): Promise<void> {
|
||||
log.info('Syncing (push + pull)...\n');
|
||||
|
||||
// Push first
|
||||
log.info('--- Push ---');
|
||||
await push({ ...options, all: true });
|
||||
|
||||
console.log('');
|
||||
|
||||
// Then pull
|
||||
log.info('--- Pull ---');
|
||||
await pull(options);
|
||||
|
||||
console.log('');
|
||||
log.success('Sync complete');
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ─── Tool path config ───────────────────────────────────
|
||||
|
||||
export const ToolPathsSchema = z.object({
|
||||
skills: z.string(),
|
||||
settings: z.string().optional(),
|
||||
claudemd: z.string().optional(),
|
||||
});
|
||||
|
||||
// ─── Team config (tad.yaml) ─────────────────────────────
|
||||
|
||||
export const SharingConfigSchema = z.object({
|
||||
skills: z.object({
|
||||
syncTargets: z.array(z.string()).default(['claude', 'codex', 'claude-internal', 'cursor']),
|
||||
}).default({}),
|
||||
rules: z.object({
|
||||
enforced: z.array(z.string()).default([]),
|
||||
}).default({}),
|
||||
docs: z.object({
|
||||
localDir: z.string().default('~/.tad/docs'),
|
||||
}).default({}),
|
||||
});
|
||||
|
||||
export const TadConfigSchema = z.object({
|
||||
team: z.string(),
|
||||
description: z.string().default(''),
|
||||
repo: z.string(),
|
||||
sharing: SharingConfigSchema.default({}),
|
||||
toolPaths: z.record(z.string(), ToolPathsSchema).default({
|
||||
claude: { skills: '.claude/skills', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md' },
|
||||
codex: { skills: '.codex/skills' },
|
||||
'claude-internal': { skills: '.claude-internal/skills', settings: '.claude-internal/settings.json' },
|
||||
cursor: { skills: '.cursor/skills-cursor' },
|
||||
}),
|
||||
});
|
||||
|
||||
export type TadConfig = z.infer<typeof TadConfigSchema>;
|
||||
|
||||
// ─── Member config (members/<user>.yaml) ────────────────
|
||||
|
||||
export const MemberConfigSchema = z.object({
|
||||
username: z.string(),
|
||||
displayName: z.string().default(''),
|
||||
registeredAt: z.string(),
|
||||
});
|
||||
|
||||
export type MemberConfig = z.infer<typeof MemberConfigSchema>;
|
||||
|
||||
// ─── Local config (~/.tad/config.yaml) ──────────────────
|
||||
|
||||
export const LocalConfigSchema = z.object({
|
||||
repo: z.object({
|
||||
localPath: z.string(),
|
||||
remote: z.string(),
|
||||
}),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
export type LocalConfig = z.infer<typeof LocalConfigSchema>;
|
||||
|
||||
// ─── Local state (~/.tad/state.json) ────────────────────
|
||||
|
||||
export const StateSchema = z.object({
|
||||
lastPush: z.string().nullable().default(null),
|
||||
lastPull: z.string().nullable().default(null),
|
||||
pushedInstincts: z.array(z.string()).default([]),
|
||||
pushedSkills: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export type State = z.infer<typeof StateSchema>;
|
||||
|
||||
// ─── Resource types ─────────────────────────────────────
|
||||
|
||||
export type ResourceType = 'skills' | 'rules' | 'hooks' | 'docs' | 'instincts';
|
||||
|
||||
export interface ResourceItem {
|
||||
name: string;
|
||||
type: ResourceType;
|
||||
sourcePath: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export interface ResourceDiff {
|
||||
added: ResourceItem[];
|
||||
modified: ResourceItem[];
|
||||
removed: ResourceItem[];
|
||||
}
|
||||
|
||||
// ─── Global options ─────────────────────────────────────
|
||||
|
||||
export interface GlobalOptions {
|
||||
dryRun?: boolean;
|
||||
verbose?: boolean;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────
|
||||
|
||||
export const TAD_HOME = `${process.env.HOME}/.tad`;
|
||||
export const TAD_CONFIG_PATH = `${TAD_HOME}/config.yaml`;
|
||||
export const TAD_STATE_PATH = `${TAD_HOME}/state.json`;
|
||||
export const TAD_TOKEN_PATH = `${TAD_HOME}/token`;
|
||||
|
||||
export const RESOURCE_TYPES: ResourceType[] = ['skills', 'rules', 'hooks', 'docs', 'instincts'];
|
||||
|
||||
export const TAD_RULES_START = '<!-- [tad:rules:start] -->';
|
||||
export const TAD_RULES_END = '<!-- [tad:rules:end] -->';
|
||||
|
||||
export const TAD_HOOK_DESCRIPTION_PREFIX = '[tad]';
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import fse from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import { log } from './logger.js';
|
||||
|
||||
/**
|
||||
* Expand ~ to $HOME in paths
|
||||
*/
|
||||
export function expandHome(p: string): string {
|
||||
if (p.startsWith('~/') || p === '~') {
|
||||
return path.join(process.env.HOME ?? '', p.slice(1));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists
|
||||
*/
|
||||
export async function ensureDir(dir: string): Promise<void> {
|
||||
await fse.ensureDir(expandHome(dir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file, return null if not found
|
||||
*/
|
||||
export async function readFileSafe(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await fse.readFile(expandHome(filePath), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a file, creating parent dirs as needed
|
||||
*/
|
||||
export async function writeFile(filePath: string, content: string): Promise<void> {
|
||||
const expanded = expandHome(filePath);
|
||||
await fse.ensureDir(path.dirname(expanded));
|
||||
await fse.writeFile(expanded, content, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read JSON file, return null if not found
|
||||
*/
|
||||
export async function readJson<T = unknown>(filePath: string): Promise<T | null> {
|
||||
const content = await readFileSafe(filePath);
|
||||
if (content === null) return null;
|
||||
try {
|
||||
return JSON.parse(content) as T;
|
||||
} catch {
|
||||
log.warn(`Failed to parse JSON: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write JSON file
|
||||
*/
|
||||
export async function writeJson(filePath: string, data: unknown): Promise<void> {
|
||||
await writeFile(filePath, JSON.stringify(data, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a directory recursively
|
||||
*/
|
||||
export async function copyDir(src: string, dest: string): Promise<void> {
|
||||
await fse.copy(expandHome(src), expandHome(dest), { overwrite: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file
|
||||
*/
|
||||
export async function copyFile(src: string, dest: string): Promise<void> {
|
||||
const destExpanded = expandHome(dest);
|
||||
await fse.ensureDir(path.dirname(destExpanded));
|
||||
await fse.copy(expandHome(src), destExpanded, { overwrite: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* List directories in a path (non-recursive, only directories)
|
||||
*/
|
||||
export async function listDirs(dirPath: string): Promise<string[]> {
|
||||
const expanded = expandHome(dirPath);
|
||||
if (!await fse.pathExists(expanded)) return [];
|
||||
const entries = await fse.readdir(expanded, { withFileTypes: true });
|
||||
return entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a path (non-recursive, only files)
|
||||
*/
|
||||
export async function listFiles(dirPath: string): Promise<string[]> {
|
||||
const expanded = expandHome(dirPath);
|
||||
if (!await fse.pathExists(expanded)) return [];
|
||||
const entries = await fse.readdir(expanded, { withFileTypes: true });
|
||||
return entries.filter(e => e.isFile()).map(e => e.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path exists
|
||||
*/
|
||||
export async function pathExists(p: string): Promise<boolean> {
|
||||
return fse.pathExists(expandHome(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file or directory
|
||||
*/
|
||||
export async function remove(p: string): Promise<void> {
|
||||
await fse.remove(expandHome(p));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import simpleGit, { type SimpleGit } from 'simple-git';
|
||||
import { log } from './logger.js';
|
||||
|
||||
export function createGit(basePath?: string): SimpleGit {
|
||||
return simpleGit(basePath ? { baseDir: basePath } : undefined);
|
||||
}
|
||||
|
||||
export async function cloneRepo(remote: string, localPath: string): Promise<void> {
|
||||
const git = simpleGit();
|
||||
await git.clone(remote, localPath);
|
||||
}
|
||||
|
||||
export async function pullRepo(localPath: string): Promise<string> {
|
||||
const git = createGit(localPath);
|
||||
const result = await git.pull();
|
||||
if (result.summary.changes === 0 && result.summary.insertions === 0 && result.summary.deletions === 0) {
|
||||
return 'already up to date';
|
||||
}
|
||||
return `${result.summary.changes} file(s) changed`;
|
||||
}
|
||||
|
||||
export async function pushRepo(localPath: string, message: string, files: string[]): Promise<void> {
|
||||
const git = createGit(localPath);
|
||||
await git.add(files);
|
||||
const status = await git.status();
|
||||
if (status.staged.length === 0) {
|
||||
log.debug('Nothing to commit');
|
||||
return;
|
||||
}
|
||||
await git.commit(message);
|
||||
await git.push();
|
||||
}
|
||||
|
||||
export async function getRepoStatus(localPath: string): Promise<{ ahead: number; behind: number; modified: string[] }> {
|
||||
const git = createGit(localPath);
|
||||
await git.fetch();
|
||||
const status = await git.status();
|
||||
return {
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
modified: [...status.modified, ...status.not_added, ...status.created],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import chalk from 'chalk';
|
||||
import ora, { type Ora } from 'ora';
|
||||
|
||||
let verboseEnabled = false;
|
||||
let silentMode = false;
|
||||
|
||||
export function setVerbose(v: boolean): void {
|
||||
verboseEnabled = v;
|
||||
}
|
||||
|
||||
export function setSilent(s: boolean): void {
|
||||
silentMode = s;
|
||||
}
|
||||
|
||||
export const log = {
|
||||
info(msg: string): void {
|
||||
if (silentMode) return;
|
||||
console.log(chalk.blue('ℹ'), msg);
|
||||
},
|
||||
success(msg: string): void {
|
||||
if (silentMode) return;
|
||||
console.log(chalk.green('✔'), msg);
|
||||
},
|
||||
warn(msg: string): void {
|
||||
if (silentMode) return;
|
||||
console.log(chalk.yellow('⚠'), msg);
|
||||
},
|
||||
error(msg: string): void {
|
||||
console.error(chalk.red('✖'), msg);
|
||||
},
|
||||
debug(msg: string): void {
|
||||
if (!verboseEnabled || silentMode) return;
|
||||
console.log(chalk.gray(' [debug]'), msg);
|
||||
},
|
||||
dim(msg: string): void {
|
||||
if (silentMode) return;
|
||||
console.log(chalk.dim(msg));
|
||||
},
|
||||
};
|
||||
|
||||
export function spinner(text: string): Ora {
|
||||
if (silentMode) {
|
||||
// return a no-op spinner in silent mode
|
||||
return ora({ text, isSilent: true });
|
||||
}
|
||||
return ora({ text, color: 'cyan' });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { log } from './logger.js';
|
||||
|
||||
const TGIT_API_BASE = 'https://git.woa.com/api/v3';
|
||||
|
||||
export interface TGitUser {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
const token = process.env.TGIT_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error('TGIT_TOKEN environment variable is not set. Get one from https://git.woa.com/profile/personal_access_tokens');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function tgitFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||
const token = getToken();
|
||||
// Use query parameter auth for compatibility with all TGit v3 endpoints
|
||||
const separator = path.includes('?') ? '&' : '?';
|
||||
const url = `${TGIT_API_BASE}${path}${separator}private_token=${token}`;
|
||||
log.debug(`TGit API: ${options?.method ?? 'GET'} ${TGIT_API_BASE}${path}`);
|
||||
const resp = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '');
|
||||
throw new Error(`TGit API error ${resp.status}: ${body}`);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<TGitUser> {
|
||||
const resp = await tgitFetch('/user');
|
||||
return resp.json() as Promise<TGitUser>;
|
||||
}
|
||||
|
||||
export async function verifyToken(): Promise<TGitUser> {
|
||||
try {
|
||||
return await getCurrentUser();
|
||||
} catch (e) {
|
||||
throw new Error(`TGit token verification failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
dts: false,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user