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:
jeffyxu
2026-03-03 14:38:43 +08:00
commit 8cb1fbacab
28 changed files with 3903 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tgz
.DS_Store
+80
View File
@@ -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
```
+1979
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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"
}
}
+85
View File
@@ -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 };
}
+76
View File
@@ -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.');
}
}
+95
View File
@@ -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}`);
}
}
}
}
+94
View File
@@ -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
View File
@@ -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.');
}
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+64
View File
@@ -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 };
}
}
+44
View File
@@ -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}`);
}
}
}
+111
View File
@@ -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}`);
}
}
}
}
}
+25
View File
@@ -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 };
+124
View File
@@ -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}`);
}
}
}
+96
View File
@@ -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}`);
}
}
}
}
+88
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
+43
View File
@@ -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],
};
}
+47
View File
@@ -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' });
}
+50
View File
@@ -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}`);
}
}
+16
View File
@@ -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/**/*"]
}
+15
View File
@@ -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',
},
});