team-toon-tack 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # team-toon-tack (ttt)
2
+
3
+ CLI tool for syncing and managing Linear issues with local TOON format.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # npm (recommended)
9
+ npm install -g team-toon-tack
10
+
11
+ # Or with bun
12
+ bun add -g team-toon-tack
13
+
14
+ # Or use npx/bunx without installing
15
+ npx team-toon-tack <command>
16
+ bunx team-toon-tack <command>
17
+
18
+ # Short alias after global install
19
+ ttt <command>
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ # 1. Set your Linear API key
26
+ export LINEAR_API_KEY="lin_api_xxxxx"
27
+
28
+ # 2. Initialize in your project
29
+ cd your-project
30
+ ttt init
31
+
32
+ # 3. Sync issues from Linear
33
+ ttt sync
34
+
35
+ # 4. Start working
36
+ ttt work-on
37
+ ```
38
+
39
+ ## Commands
40
+
41
+ ### `ttt init`
42
+
43
+ Initialize configuration files in current directory.
44
+
45
+ ```bash
46
+ ttt init # Interactive mode
47
+ ttt init --user alice@example.com # Pre-select user
48
+ ttt init --label Frontend # Set default label
49
+ ttt init --force # Overwrite existing config
50
+ ttt init -y # Non-interactive mode
51
+ ```
52
+
53
+ ### `ttt sync`
54
+
55
+ Sync current cycle issues from Linear.
56
+
57
+ ```bash
58
+ ttt sync
59
+ ```
60
+
61
+ ### `ttt work-on`
62
+
63
+ Start working on a task.
64
+
65
+ ```bash
66
+ ttt work-on # Interactive selection
67
+ ttt work-on MP-123 # Specific issue
68
+ ttt work-on next # Auto-select highest priority
69
+ ```
70
+
71
+ ### `ttt done`
72
+
73
+ Mark task as completed.
74
+
75
+ ```bash
76
+ ttt done # Auto-select if only one in-progress
77
+ ttt done MP-123 # Specific issue
78
+ ttt done -m "Fixed the bug" # With completion message
79
+ ```
80
+
81
+ ## Configuration
82
+
83
+ ### Directory Structure
84
+
85
+ After `ttt init`, your project will have:
86
+
87
+ ```
88
+ your-project/
89
+ ├── config.toon # Team configuration (gitignore recommended)
90
+ ├── local.toon # Personal settings (gitignore)
91
+ └── cycle.toon # Current cycle data (gitignore, auto-generated)
92
+ ```
93
+
94
+ ### Custom Config Directory
95
+
96
+ ```bash
97
+ # Use -d flag
98
+ ttt sync -d ./team
99
+
100
+ # Or set environment variable
101
+ export TOON_DIR=./team
102
+ ttt sync
103
+ ```
104
+
105
+ ### config.toon
106
+
107
+ Team-wide configuration (fetched from Linear):
108
+
109
+ ```toon
110
+ teams:
111
+ main:
112
+ id: TEAM_UUID
113
+ name: Team Name
114
+
115
+ users:
116
+ alice:
117
+ id: USER_UUID
118
+ email: alice@example.com
119
+ displayName: Alice
120
+
121
+ labels:
122
+ frontend:
123
+ id: LABEL_UUID
124
+ name: Frontend
125
+
126
+ current_cycle:
127
+ id: CYCLE_UUID
128
+ name: Cycle #1
129
+ ```
130
+
131
+ ### local.toon
132
+
133
+ Personal settings:
134
+
135
+ ```toon
136
+ current_user: alice
137
+ label: Frontend
138
+ exclude_assignees[1]: bob
139
+ ```
140
+
141
+ | Field | Description |
142
+ |-------|-------------|
143
+ | `current_user` | Your user key from config.toon |
144
+ | `label` | Filter issues by label (optional) |
145
+ | `exclude_assignees` | Hide issues from these users (optional) |
146
+
147
+ ## Environment Variables
148
+
149
+ | Variable | Description |
150
+ |----------|-------------|
151
+ | `LINEAR_API_KEY` | **Required.** Your Linear API key |
152
+ | `TOON_DIR` | Config directory (default: current directory) |
153
+
154
+ ## Integration Examples
155
+
156
+ ### With Claude Code
157
+
158
+ ```yaml
159
+ # .claude/commands/sync.md
160
+ ---
161
+ description: Sync Linear issues
162
+ ---
163
+ ttt sync -d team
164
+ ```
165
+
166
+ ### As Git Submodule
167
+
168
+ ```bash
169
+ # Add config directory as submodule
170
+ git submodule add https://github.com/your-org/team-config.git team
171
+ cd team && ttt sync
172
+ ```
173
+
174
+ ### In package.json
175
+
176
+ ```json
177
+ {
178
+ "scripts": {
179
+ "sync": "ttt sync -d team",
180
+ "work": "ttt work-on -d team"
181
+ }
182
+ }
183
+ ```
184
+
185
+ ## License
186
+
187
+ MIT
package/bin/cli.ts ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bun
2
+ import { resolve } from 'node:path';
3
+
4
+ const COMMANDS = ['init', 'sync', 'work-on', 'done', 'help', 'version'] as const;
5
+ type Command = typeof COMMANDS[number];
6
+
7
+ function printHelp() {
8
+ console.log(`
9
+ team-toon-tack (ttt) - Linear task sync & management CLI
10
+
11
+ USAGE:
12
+ ttt <command> [options]
13
+
14
+ COMMANDS:
15
+ init Initialize config files in current directory
16
+ sync Sync issues from Linear to local cycle.toon
17
+ work-on Start working on a task (interactive or by ID)
18
+ done Mark current task as completed
19
+ help Show this help message
20
+ version Show version
21
+
22
+ GLOBAL OPTIONS:
23
+ -d, --dir <path> Config directory (default: current directory)
24
+ Can also set via TOON_DIR environment variable
25
+
26
+ EXAMPLES:
27
+ ttt init # Initialize in current directory
28
+ ttt init -d ./team # Initialize in ./team directory
29
+ ttt sync # Sync from Linear
30
+ ttt work-on # Interactive task selection
31
+ ttt work-on MP-123 # Work on specific issue
32
+ ttt work-on next # Auto-select highest priority
33
+ ttt done # Complete current task
34
+ ttt done -m "Fixed the bug" # With completion message
35
+
36
+ ENVIRONMENT:
37
+ LINEAR_API_KEY Required. Your Linear API key
38
+ TOON_DIR Optional. Default config directory
39
+
40
+ More info: https://github.com/wayne930242/team-toon-tack
41
+ `);
42
+ }
43
+
44
+ function printVersion() {
45
+ console.log('team-toon-tack v1.0.0');
46
+ }
47
+
48
+ function parseGlobalArgs(args: string[]): { dir: string; commandArgs: string[] } {
49
+ let dir = process.env.TOON_DIR || process.cwd();
50
+ const commandArgs: string[] = [];
51
+
52
+ for (let i = 0; i < args.length; i++) {
53
+ const arg = args[i];
54
+ if (arg === '-d' || arg === '--dir') {
55
+ dir = resolve(args[++i] || '.');
56
+ } else {
57
+ commandArgs.push(arg);
58
+ }
59
+ }
60
+
61
+ return { dir, commandArgs };
62
+ }
63
+
64
+ async function main() {
65
+ const args = process.argv.slice(2);
66
+
67
+ if (args.length === 0 || args[0] === 'help' || args[0] === '-h' || args[0] === '--help') {
68
+ printHelp();
69
+ process.exit(0);
70
+ }
71
+
72
+ if (args[0] === 'version' || args[0] === '-v' || args[0] === '--version') {
73
+ printVersion();
74
+ process.exit(0);
75
+ }
76
+
77
+ const command = args[0] as Command;
78
+ const restArgs = args.slice(1);
79
+ const { dir, commandArgs } = parseGlobalArgs(restArgs);
80
+
81
+ // Set TOON_DIR for scripts to use
82
+ process.env.TOON_DIR = dir;
83
+
84
+ if (!COMMANDS.includes(command)) {
85
+ console.error(`Unknown command: ${command}`);
86
+ console.error(`Run 'ttt help' for usage.`);
87
+ process.exit(1);
88
+ }
89
+
90
+ // Import and run the appropriate script
91
+ const scriptDir = new URL('../scripts/', import.meta.url).pathname;
92
+
93
+ try {
94
+ switch (command) {
95
+ case 'init':
96
+ process.argv = ['bun', 'init.ts', ...commandArgs];
97
+ await import(`${scriptDir}init.ts`);
98
+ break;
99
+ case 'sync':
100
+ await import(`${scriptDir}sync.ts`);
101
+ break;
102
+ case 'work-on':
103
+ process.argv = ['bun', 'work-on.ts', ...commandArgs];
104
+ await import(`${scriptDir}work-on.ts`);
105
+ break;
106
+ case 'done':
107
+ process.argv = ['bun', 'done-job.ts', ...commandArgs];
108
+ await import(`${scriptDir}done-job.ts`);
109
+ break;
110
+ }
111
+ } catch (error) {
112
+ if (error instanceof Error) {
113
+ console.error(`Error: ${error.message}`);
114
+ }
115
+ process.exit(1);
116
+ }
117
+ }
118
+
119
+ main();
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "team-toon-tack",
3
+ "version": "1.0.0",
4
+ "description": "Linear task sync & management CLI with TOON format",
5
+ "type": "module",
6
+ "bin": {
7
+ "ttt": "./bin/cli.ts",
8
+ "team-toon-tack": "./bin/cli.ts"
9
+ },
10
+ "exports": {
11
+ ".": "./scripts/utils.ts",
12
+ "./utils": "./scripts/utils.ts"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "scripts",
17
+ "templates"
18
+ ],
19
+ "scripts": {
20
+ "start": "bun bin/cli.ts",
21
+ "build": "bun build bin/cli.ts --outdir dist --target node",
22
+ "prepublishOnly": "bun run build"
23
+ },
24
+ "dependencies": {
25
+ "@linear/sdk": "^29.0.0",
26
+ "@toon-format/toon": "^2.0.0",
27
+ "prompts": "^2.4.2"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "^1.2.0",
31
+ "@types/prompts": "^2.4.9"
32
+ },
33
+ "keywords": [
34
+ "linear",
35
+ "task-management",
36
+ "cli",
37
+ "toon",
38
+ "sync",
39
+ "project-management"
40
+ ],
41
+ "author": "wayne930242",
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/wayne930242/team-toon-tack.git"
46
+ },
47
+ "engines": {
48
+ "node": ">=18.0.0",
49
+ "bun": ">=1.0.0"
50
+ }
51
+ }
@@ -0,0 +1,242 @@
1
+ import prompts from 'prompts';
2
+ import { execSync } from 'node:child_process';
3
+ import { getLinearClient, loadConfig, loadCycleData, saveCycleData, getTeamId, getDefaultTeamKey } from './utils';
4
+
5
+ interface CommitInfo {
6
+ shortHash: string;
7
+ fullHash: string;
8
+ message: string;
9
+ diffStat: string;
10
+ commitUrl: string | null;
11
+ }
12
+
13
+ async function getLatestCommit(): Promise<CommitInfo | null> {
14
+ try {
15
+ const shortHash = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
16
+ const fullHash = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
17
+ const message = execSync('git log -1 --format=%s', { encoding: 'utf-8' }).trim();
18
+ const diffStat = execSync('git diff HEAD~1 --stat --stat-width=60', { encoding: 'utf-8' }).trim();
19
+
20
+ // Get remote URL and construct commit link
21
+ let commitUrl: string | null = null;
22
+ try {
23
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
24
+ // Handle SSH or HTTPS URLs
25
+ // git@gitlab.com:org/repo.git -> https://gitlab.com/org/repo/-/commit/hash
26
+ // https://gitlab.com/org/repo.git -> https://gitlab.com/org/repo/-/commit/hash
27
+ if (remoteUrl.includes('gitlab')) {
28
+ const match = remoteUrl.match(/(?:git@|https:\/\/)([^:\/]+)[:\\/](.+?)(?:\.git)?$/);
29
+ if (match) {
30
+ commitUrl = `https://${match[1]}/${match[2]}/-/commit/${fullHash}`;
31
+ }
32
+ } else if (remoteUrl.includes('github')) {
33
+ const match = remoteUrl.match(/(?:git@|https:\/\/)([^:\/]+)[:\\/](.+?)(?:\.git)?$/);
34
+ if (match) {
35
+ commitUrl = `https://${match[1]}/${match[2]}/commit/${fullHash}`;
36
+ }
37
+ }
38
+ } catch {
39
+ // Ignore if can't get remote URL
40
+ }
41
+
42
+ return { shortHash, fullHash, message, diffStat, commitUrl };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function parseArgs(args: string[]): { issueId?: string; message?: string } {
49
+ let issueId: string | undefined;
50
+ let message: string | undefined;
51
+
52
+ for (let i = 0; i < args.length; i++) {
53
+ const arg = args[i];
54
+ if (arg === '-m' || arg === '--message') {
55
+ message = args[++i];
56
+ } else if (!arg.startsWith('-')) {
57
+ issueId = arg;
58
+ }
59
+ }
60
+
61
+ return { issueId, message };
62
+ }
63
+
64
+ async function doneJob() {
65
+ const { issueId: argIssueId, message: argMessage } = parseArgs(process.argv.slice(2));
66
+ let issueId = argIssueId;
67
+
68
+ const config = await loadConfig();
69
+ const data = await loadCycleData();
70
+
71
+ if (!data) {
72
+ console.error('No cycle data found. Run /sync-linear first.');
73
+ process.exit(1);
74
+ }
75
+
76
+ // Find in-progress tasks
77
+ const inProgressTasks = data.tasks.filter(t => t.localStatus === 'in-progress');
78
+
79
+ if (inProgressTasks.length === 0) {
80
+ console.log('沒有進行中的任務');
81
+ process.exit(0);
82
+ }
83
+
84
+ // Phase 0: Issue Resolution
85
+ if (!issueId) {
86
+ if (inProgressTasks.length === 1) {
87
+ issueId = inProgressTasks[0].id;
88
+ console.log(`Auto-selected: ${issueId}`);
89
+ } else if (process.stdin.isTTY) {
90
+ const choices = inProgressTasks.map(task => ({
91
+ title: `${task.id}: ${task.title}`,
92
+ value: task.id,
93
+ description: task.labels.join(', ')
94
+ }));
95
+
96
+ const response = await prompts({
97
+ type: 'select',
98
+ name: 'issueId',
99
+ message: '選擇要完成的任務:',
100
+ choices: choices
101
+ });
102
+
103
+ if (!response.issueId) {
104
+ console.log('已取消');
105
+ process.exit(0);
106
+ }
107
+ issueId = response.issueId;
108
+ } else {
109
+ console.error('多個進行中任務,請指定 issue ID:');
110
+ inProgressTasks.forEach(t => console.log(` - ${t.id}: ${t.title}`));
111
+ process.exit(1);
112
+ }
113
+ }
114
+
115
+ // Phase 1: Find task
116
+ const task = data.tasks.find(t => t.id === issueId || t.id === `MP-${issueId}`);
117
+ if (!task) {
118
+ console.error(`Issue ${issueId} not found in current cycle.`);
119
+ process.exit(1);
120
+ }
121
+
122
+ if (task.localStatus !== 'in-progress') {
123
+ console.log(`⚠️ 任務 ${task.id} 不在進行中狀態 (目前: ${task.localStatus})`);
124
+ process.exit(1);
125
+ }
126
+
127
+ // Get latest commit for comment
128
+ const commit = await getLatestCommit();
129
+
130
+ // Phase 2: Get AI summary message
131
+ let aiMessage = argMessage || '';
132
+ if (!aiMessage && process.stdin.isTTY) {
133
+ const aiMsgResponse = await prompts({
134
+ type: 'text',
135
+ name: 'aiMessage',
136
+ message: 'AI 修復說明 (如何解決此問題):',
137
+ });
138
+ aiMessage = aiMsgResponse.aiMessage || '';
139
+ }
140
+
141
+ // Phase 3: Update Linear
142
+ if (task.linearId && process.env.LINEAR_API_KEY) {
143
+ try {
144
+ const client = getLinearClient();
145
+ const workflowStates = await client.workflowStates({
146
+ filter: { team: { id: { eq: getTeamId(config) } } }
147
+ });
148
+ const doneState = workflowStates.nodes.find(s => s.name === 'Done');
149
+
150
+ // Update issue to Done
151
+ if (doneState) {
152
+ await client.updateIssue(task.linearId, { stateId: doneState.id });
153
+ console.log(`Linear: ${task.id} → Done`);
154
+ }
155
+
156
+ // Add comment with commit info and AI summary
157
+ if (commit) {
158
+ const commitLink = commit.commitUrl
159
+ ? `[${commit.shortHash}](${commit.commitUrl})`
160
+ : `\`${commit.shortHash}\``;
161
+
162
+ const commentParts = [
163
+ '## ✅ 開發完成',
164
+ '',
165
+ '### 🤖 AI 修復說明',
166
+ aiMessage || '_No description provided_',
167
+ '',
168
+ '### 📝 Commit Info',
169
+ `**Commit:** ${commitLink}`,
170
+ `**Message:** ${commit.message}`,
171
+ '',
172
+ '### 📊 Changes',
173
+ '```',
174
+ commit.diffStat,
175
+ '```',
176
+ ];
177
+
178
+ await client.createComment({
179
+ issueId: task.linearId,
180
+ body: commentParts.join('\n')
181
+ });
182
+ console.log(`Linear: 已新增 commit 留言`);
183
+ }
184
+
185
+ // Update parent to Testing if exists
186
+ if (task.parentIssueId) {
187
+ try {
188
+ // Find parent issue by identifier
189
+ const searchResult = await client.searchIssues(task.parentIssueId);
190
+ const parentIssue = searchResult.nodes.find(
191
+ issue => issue.identifier === task.parentIssueId
192
+ );
193
+
194
+ if (parentIssue) {
195
+ // Get parent's team workflow states
196
+ const parentTeam = await parentIssue.team;
197
+ if (parentTeam) {
198
+ const parentWorkflowStates = await client.workflowStates({
199
+ filter: { team: { id: { eq: parentTeam.id } } }
200
+ });
201
+ const testingState = parentWorkflowStates.nodes.find(s => s.name === 'Testing');
202
+
203
+ if (testingState) {
204
+ await client.updateIssue(parentIssue.id, { stateId: testingState.id });
205
+ console.log(`Linear: Parent ${task.parentIssueId} → Testing`);
206
+ }
207
+ }
208
+ }
209
+ } catch (parentError) {
210
+ console.error('Failed to update parent issue:', parentError);
211
+ }
212
+ }
213
+ } catch (e) {
214
+ console.error('Failed to update Linear:', e);
215
+ }
216
+ }
217
+
218
+ // Phase 4: Update local status
219
+ task.localStatus = 'completed';
220
+ await saveCycleData(data);
221
+ console.log(`Local: ${task.id} → completed`);
222
+
223
+ // Phase 5: Summary
224
+ console.log(`\n${'═'.repeat(50)}`);
225
+ console.log(`✅ ${task.id}: ${task.title}`);
226
+ console.log(`${'═'.repeat(50)}`);
227
+ if (commit) {
228
+ console.log(`Commit: ${commit.shortHash} - ${commit.message}`);
229
+ if (commit.commitUrl) {
230
+ console.log(`URL: ${commit.commitUrl}`);
231
+ }
232
+ }
233
+ if (aiMessage) {
234
+ console.log(`AI: ${aiMessage}`);
235
+ }
236
+ if (task.parentIssueId) {
237
+ console.log(`Parent: ${task.parentIssueId} → Testing`);
238
+ }
239
+ console.log(`\n🎉 任務完成!`);
240
+ }
241
+
242
+ doneJob().catch(console.error);