@bretwardjames/tw-bridge 0.2.0 → 0.4.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/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import fs2 from "fs";
5
- import path3 from "path";
6
- import os2 from "os";
4
+ import fs3 from "fs";
5
+ import path4 from "path";
6
+ import os3 from "os";
7
7
 
8
8
  // src/config.ts
9
9
  import fs from "fs";
@@ -30,7 +30,7 @@ function loadConfig() {
30
30
  function saveConfig(config) {
31
31
  const configPath = findConfigPath() ?? CONFIG_PATHS[0];
32
32
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
33
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
33
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
34
34
  return configPath;
35
35
  }
36
36
 
@@ -125,9 +125,11 @@ var GhpAdapter = class {
125
125
  process.stderr.write("tw-bridge [ghp]: task has no backend_id, skipping ghp start\n");
126
126
  return;
127
127
  }
128
- process.stderr.write(`tw-bridge [ghp]: starting issue #${issueNumber}
128
+ const parallel = process.env.TW_BRIDGE_MODE === "parallel";
129
+ const args = parallel ? ["start", issueNumber, "--parallel"] : ["start", issueNumber];
130
+ process.stderr.write(`tw-bridge [ghp]: starting issue #${issueNumber}${parallel ? " (parallel)" : ""}
129
131
  `);
130
- const result = spawnSync("ghp", ["start", issueNumber], {
132
+ const result = spawnSync("ghp", args, {
131
133
  cwd: this.config.cwd,
132
134
  stdio: [ttyFd, ttyFd, ttyFd]
133
135
  });
@@ -150,9 +152,430 @@ var GhpAdapter = class {
150
152
  }
151
153
  };
152
154
 
155
+ // src/adapters/asana.ts
156
+ import fs2 from "fs";
157
+ import path3 from "path";
158
+ import os2 from "os";
159
+ import { execSync } from "child_process";
160
+ import { createInterface } from "readline/promises";
161
+ import { stdin, stdout } from "process";
162
+ var API_BASE = "https://app.asana.com/api/1.0";
163
+ var OAUTH_AUTHORIZE = "https://app.asana.com/-/oauth_authorize";
164
+ var OAUTH_TOKEN = "https://app.asana.com/-/oauth_token";
165
+ var OOB_REDIRECT = "urn:ietf:wg:oauth:2.0:oob";
166
+ var TOKEN_FILE = path3.join(os2.homedir(), ".config", "tw-bridge", "asana-tokens.json");
167
+ function loadTokens() {
168
+ try {
169
+ return JSON.parse(fs2.readFileSync(TOKEN_FILE, "utf-8"));
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+ function saveTokens(tokens) {
175
+ fs2.mkdirSync(path3.dirname(TOKEN_FILE), { recursive: true });
176
+ fs2.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 384 });
177
+ }
178
+ async function refreshAccessToken(tokens) {
179
+ const now = Date.now();
180
+ if (tokens.access_token && tokens.expires_at > now + 3e5) {
181
+ return tokens.access_token;
182
+ }
183
+ const res = await fetch(OAUTH_TOKEN, {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
186
+ body: new URLSearchParams({
187
+ grant_type: "refresh_token",
188
+ client_id: tokens.client_id,
189
+ client_secret: tokens.client_secret,
190
+ refresh_token: tokens.refresh_token
191
+ })
192
+ });
193
+ if (!res.ok) {
194
+ const body = await res.text();
195
+ throw new Error(`Token refresh failed (${res.status}): ${body}`);
196
+ }
197
+ const data = await res.json();
198
+ tokens.access_token = data.access_token;
199
+ tokens.expires_at = now + (data.expires_in ?? 3600) * 1e3;
200
+ saveTokens(tokens);
201
+ return tokens.access_token;
202
+ }
203
+ async function oauthLogin(clientId, clientSecret, rl) {
204
+ const authUrl = `${OAUTH_AUTHORIZE}?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(OOB_REDIRECT)}&response_type=code`;
205
+ console.log(`
206
+ Opening browser for authorization...`);
207
+ console.log(` If it doesn't open, visit:
208
+ ${authUrl}
209
+ `);
210
+ try {
211
+ execSync(`xdg-open "${authUrl}" 2>/dev/null || open "${authUrl}" 2>/dev/null`, {
212
+ stdio: "ignore"
213
+ });
214
+ } catch {
215
+ }
216
+ const code = (await rl.question(" Paste the authorization code: ")).trim();
217
+ if (!code) throw new Error("No authorization code provided");
218
+ const tokenRes = await fetch(OAUTH_TOKEN, {
219
+ method: "POST",
220
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
221
+ body: new URLSearchParams({
222
+ grant_type: "authorization_code",
223
+ client_id: clientId,
224
+ client_secret: clientSecret,
225
+ redirect_uri: OOB_REDIRECT,
226
+ code
227
+ })
228
+ });
229
+ if (!tokenRes.ok) {
230
+ const body = await tokenRes.text();
231
+ throw new Error(`Token exchange failed (${tokenRes.status}): ${body}`);
232
+ }
233
+ const data = await tokenRes.json();
234
+ const tokens = {
235
+ access_token: data.access_token,
236
+ refresh_token: data.refresh_token,
237
+ expires_at: Date.now() + (data.expires_in ?? 3600) * 1e3,
238
+ client_id: clientId,
239
+ client_secret: clientSecret
240
+ };
241
+ saveTokens(tokens);
242
+ return tokens;
243
+ }
244
+ async function asanaFetch(path5, token, options = {}) {
245
+ const url = `${API_BASE}${path5}`;
246
+ const res = await fetch(url, {
247
+ ...options,
248
+ headers: {
249
+ Authorization: `Bearer ${token}`,
250
+ "Content-Type": "application/json",
251
+ ...options.headers
252
+ }
253
+ });
254
+ if (!res.ok) {
255
+ const body = await res.text();
256
+ throw new Error(`Asana API ${res.status}: ${body}`);
257
+ }
258
+ const json = await res.json();
259
+ return json.data;
260
+ }
261
+ async function asanaFetchAll(path5, token) {
262
+ const results = [];
263
+ let nextPage = path5;
264
+ while (nextPage) {
265
+ const url = `${API_BASE}${nextPage}`;
266
+ const res = await fetch(url, {
267
+ headers: { Authorization: `Bearer ${token}` }
268
+ });
269
+ if (!res.ok) {
270
+ const body = await res.text();
271
+ throw new Error(`Asana API ${res.status}: ${body}`);
272
+ }
273
+ const json = await res.json();
274
+ results.push(...json.data);
275
+ if (json.next_page?.path) {
276
+ nextPage = json.next_page.path;
277
+ } else {
278
+ nextPage = null;
279
+ }
280
+ }
281
+ return results;
282
+ }
283
+ async function prompt(rl, question) {
284
+ return rl.question(question);
285
+ }
286
+ async function pickOne(rl, label, items) {
287
+ console.log(`
288
+ ${label}:`);
289
+ for (let i = 0; i < items.length; i++) {
290
+ console.log(` ${i + 1}) ${items[i].name}`);
291
+ }
292
+ while (true) {
293
+ const answer = await prompt(rl, `Choose (1-${items.length}): `);
294
+ const idx = parseInt(answer, 10) - 1;
295
+ if (idx >= 0 && idx < items.length) return items[idx];
296
+ console.log(" Invalid choice, try again.");
297
+ }
298
+ }
299
+ var AsanaAdapter = class {
300
+ name = "asana";
301
+ config;
302
+ accessToken = null;
303
+ /**
304
+ * Resolve the access token based on auth config.
305
+ * - "oauth": load from token file, refresh if needed
306
+ * - "env:VAR": read from environment
307
+ * - anything else: treat as raw PAT
308
+ */
309
+ async resolveToken() {
310
+ const auth = this.config.auth;
311
+ if (auth === "oauth") {
312
+ const tokens = loadTokens();
313
+ if (!tokens) throw new Error("No OAuth tokens found. Run `tw-bridge add` to authenticate.");
314
+ this.accessToken = await refreshAccessToken(tokens);
315
+ return this.accessToken;
316
+ }
317
+ if (this.accessToken) return this.accessToken;
318
+ if (auth.startsWith("env:")) {
319
+ const envVar = auth.slice(4);
320
+ const val = process.env[envVar];
321
+ if (!val) throw new Error(`Environment variable ${envVar} not set`);
322
+ this.accessToken = val;
323
+ return val;
324
+ }
325
+ this.accessToken = auth;
326
+ return auth;
327
+ }
328
+ defaultConfig(_cwd) {
329
+ return {
330
+ auth: "env:ASANA_TOKEN",
331
+ workspace_gid: "",
332
+ project_gid: "",
333
+ project_name: "",
334
+ sections: [],
335
+ done_sections: []
336
+ };
337
+ }
338
+ async setup(_cwd) {
339
+ const rl = createInterface({ input: stdin, output: stdout });
340
+ try {
341
+ console.log("\n\u2500\u2500 Asana Adapter Setup \u2500\u2500\n");
342
+ console.log("Authentication:");
343
+ console.log(" 1) OAuth (browser login)");
344
+ console.log(" 2) Personal Access Token");
345
+ console.log(" 3) Environment variable");
346
+ const authChoice = await prompt(rl, "\nChoose (1-3): ");
347
+ let authConfig;
348
+ let token;
349
+ if (authChoice.trim() === "1") {
350
+ const clientId = (await prompt(rl, "\nAsana App Client ID: ")).trim();
351
+ const clientSecret = (await prompt(rl, "Asana App Client Secret: ")).trim();
352
+ if (!clientId || !clientSecret) {
353
+ console.error("\n Client ID and Secret are required.");
354
+ console.error(" Create an app at: https://app.asana.com/0/my-apps");
355
+ console.error(' Check "This is a native or command-line app"');
356
+ rl.close();
357
+ process.exit(1);
358
+ }
359
+ const tokens = await oauthLogin(clientId, clientSecret, rl);
360
+ token = tokens.access_token;
361
+ authConfig = "oauth";
362
+ } else if (authChoice.trim() === "3") {
363
+ const envVar = await prompt(rl, "Environment variable name [ASANA_TOKEN]: ");
364
+ const varName = envVar.trim() || "ASANA_TOKEN";
365
+ authConfig = `env:${varName}`;
366
+ token = process.env[varName] ?? "";
367
+ if (!token) {
368
+ console.log(`
369
+ Warning: ${varName} is not currently set.`);
370
+ const tempToken = await prompt(rl, " Paste a temporary token for setup (or Enter to skip): ");
371
+ token = tempToken.trim();
372
+ if (!token) {
373
+ console.error(`
374
+ Cannot complete setup without a valid token.`);
375
+ console.error(` Set ${varName} in your environment and try again.`);
376
+ rl.close();
377
+ process.exit(1);
378
+ }
379
+ }
380
+ } else {
381
+ token = (await prompt(rl, "Paste your Personal Access Token: ")).trim();
382
+ authConfig = token;
383
+ }
384
+ console.log("\nVerifying...");
385
+ let me;
386
+ try {
387
+ me = await asanaFetch("/users/me", token);
388
+ } catch (err) {
389
+ const msg = err instanceof Error ? err.message : String(err);
390
+ console.error(` Authentication failed: ${msg}`);
391
+ rl.close();
392
+ process.exit(1);
393
+ }
394
+ console.log(` Authenticated as: ${me.name} (${me.email})`);
395
+ const workspaces = await asanaFetch("/workspaces", token);
396
+ let workspace;
397
+ if (workspaces.length === 1) {
398
+ workspace = workspaces[0];
399
+ console.log(`
400
+ Workspace: ${workspace.name}`);
401
+ } else {
402
+ workspace = await pickOne(rl, "Select workspace", workspaces);
403
+ }
404
+ const projects = await asanaFetchAll(
405
+ `/workspaces/${workspace.gid}/projects?limit=100&archived=false`,
406
+ token
407
+ );
408
+ if (projects.length === 0) {
409
+ console.error("\n No projects found in this workspace.");
410
+ rl.close();
411
+ process.exit(1);
412
+ }
413
+ const project = await pickOne(rl, "Select project", projects);
414
+ const sections = await asanaFetch(
415
+ `/projects/${project.gid}/sections`,
416
+ token
417
+ );
418
+ console.log(`
419
+ Sections in "${project.name}":`);
420
+ console.log("Map each section to a Taskwarrior tag (or press Enter for default).\n");
421
+ const sectionMappings = [];
422
+ const doneSections = [];
423
+ for (const section of sections) {
424
+ const defaultTag = section.name.toLowerCase().replace(/\s+/g, "_");
425
+ const tag = await prompt(rl, ` "${section.name}" \u2192 tag [${defaultTag}]: `);
426
+ const finalTag = tag.trim() || defaultTag;
427
+ sectionMappings.push({
428
+ gid: section.gid,
429
+ name: section.name,
430
+ tag: finalTag
431
+ });
432
+ }
433
+ console.log("\nWhich sections represent completed tasks?");
434
+ for (let i = 0; i < sectionMappings.length; i++) {
435
+ console.log(` ${i + 1}) ${sectionMappings[i].name} (${sectionMappings[i].tag})`);
436
+ }
437
+ const doneAnswer = await prompt(rl, "Enter numbers separated by commas (e.g., 4,5): ");
438
+ for (const num of doneAnswer.split(",")) {
439
+ const idx = parseInt(num.trim(), 10) - 1;
440
+ if (idx >= 0 && idx < sectionMappings.length) {
441
+ doneSections.push(sectionMappings[idx].gid);
442
+ }
443
+ }
444
+ console.log("\nWhich section should tasks move to on `task start`?");
445
+ for (let i = 0; i < sectionMappings.length; i++) {
446
+ console.log(` ${i + 1}) ${sectionMappings[i].name}`);
447
+ }
448
+ console.log(` 0) Don't move (no action on start)`);
449
+ const startAnswer = await prompt(rl, "Choose: ");
450
+ const startIdx = parseInt(startAnswer.trim(), 10) - 1;
451
+ const startSectionGid = startIdx >= 0 && startIdx < sectionMappings.length ? sectionMappings[startIdx].gid : void 0;
452
+ rl.close();
453
+ console.log(`
454
+ Project: ${project.name}`);
455
+ console.log(` Sections: ${sectionMappings.map((s) => s.tag).join(", ")}`);
456
+ console.log(` Done: ${doneSections.length} section(s)`);
457
+ if (startSectionGid) {
458
+ const startSection = sectionMappings.find((s) => s.gid === startSectionGid);
459
+ console.log(` On start: move to "${startSection?.name}"`);
460
+ }
461
+ return {
462
+ auth: authConfig,
463
+ workspace_gid: workspace.gid,
464
+ project_gid: project.gid,
465
+ project_name: project.name,
466
+ sections: sectionMappings,
467
+ done_sections: doneSections,
468
+ start_section_gid: startSectionGid
469
+ };
470
+ } catch (err) {
471
+ rl.close();
472
+ throw err;
473
+ }
474
+ }
475
+ async init(config) {
476
+ this.config = config;
477
+ if (!this.config.project_gid) {
478
+ throw new Error('asana adapter requires "project_gid" in config');
479
+ }
480
+ }
481
+ async pull() {
482
+ const token = await this.resolveToken();
483
+ const fields = [
484
+ "gid",
485
+ "name",
486
+ "completed",
487
+ "completed_at",
488
+ "assignee",
489
+ "assignee.name",
490
+ "memberships.project",
491
+ "memberships.project.name",
492
+ "memberships.section",
493
+ "memberships.section.name",
494
+ "permalink_url",
495
+ "custom_fields",
496
+ "custom_fields.name",
497
+ "custom_fields.display_value"
498
+ ].join(",");
499
+ const asanaTasks = await asanaFetchAll(
500
+ `/projects/${this.config.project_gid}/tasks?opt_fields=${fields}&limit=100`,
501
+ token
502
+ );
503
+ const tasks = [];
504
+ const sectionMap = new Map(
505
+ this.config.sections.map((s) => [s.gid, s])
506
+ );
507
+ for (const at of asanaTasks) {
508
+ if (at.completed) continue;
509
+ const membership = at.memberships?.find(
510
+ (m) => m.project.gid === this.config.project_gid
511
+ );
512
+ const sectionGid = membership?.section?.gid;
513
+ const sectionMapping = sectionGid ? sectionMap.get(sectionGid) : null;
514
+ const tags = [];
515
+ if (sectionMapping) tags.push(sectionMapping.tag);
516
+ const priority = mapAsanaPriority(at.custom_fields);
517
+ const task = {
518
+ uuid: "",
519
+ description: at.name,
520
+ status: "pending",
521
+ entry: (/* @__PURE__ */ new Date()).toISOString(),
522
+ project: this.config.project_name,
523
+ tags,
524
+ priority,
525
+ backend: "",
526
+ backend_id: at.gid,
527
+ annotations: at.permalink_url ? [{ entry: (/* @__PURE__ */ new Date()).toISOString(), description: at.permalink_url }] : void 0
528
+ };
529
+ tasks.push(task);
530
+ }
531
+ return tasks;
532
+ }
533
+ async onStart(task, _ttyFd) {
534
+ if (!this.config.start_section_gid) return;
535
+ const taskGid = task.backend_id;
536
+ if (!taskGid) return;
537
+ const token = await this.resolveToken();
538
+ const startSection = this.config.sections.find(
539
+ (s) => s.gid === this.config.start_section_gid
540
+ );
541
+ process.stderr.write(
542
+ `tw-bridge [asana]: moving task to "${startSection?.name ?? "In Progress"}"
543
+ `
544
+ );
545
+ await asanaFetch(`/sections/${this.config.start_section_gid}/addTask`, token, {
546
+ method: "POST",
547
+ body: JSON.stringify({ data: { task: taskGid } })
548
+ });
549
+ }
550
+ async onDone(task) {
551
+ const taskGid = task.backend_id;
552
+ if (!taskGid) return;
553
+ const token = await this.resolveToken();
554
+ process.stderr.write(`tw-bridge [asana]: marking task complete
555
+ `);
556
+ await asanaFetch(`/tasks/${taskGid}`, token, {
557
+ method: "PUT",
558
+ body: JSON.stringify({ data: { completed: true } })
559
+ });
560
+ }
561
+ };
562
+ function mapAsanaPriority(fields) {
563
+ if (!fields) return void 0;
564
+ const pField = fields.find(
565
+ (f) => f.name.toLowerCase() === "priority" && f.display_value
566
+ );
567
+ if (!pField?.display_value) return void 0;
568
+ const val = pField.display_value.toLowerCase();
569
+ if (val.startsWith("high") || val === "urgent" || val === "p0" || val === "p1") return "H";
570
+ if (val.startsWith("med") || val === "p2") return "M";
571
+ if (val.startsWith("low") || val === "p3" || val === "p4") return "L";
572
+ return void 0;
573
+ }
574
+
153
575
  // src/registry.ts
154
576
  var BUILTIN_ADAPTERS = {
155
- ghp: () => new GhpAdapter()
577
+ ghp: () => new GhpAdapter(),
578
+ asana: () => new AsanaAdapter()
156
579
  };
157
580
  function matchBackend(task, config) {
158
581
  if (task.backend && config.backends[task.backend]) {
@@ -285,11 +708,12 @@ function updateTaskDescription(existing, newDescription) {
285
708
  }
286
709
 
287
710
  // src/cli.ts
288
- var HOOKS_DIR = path3.join(os2.homedir(), ".task", "hooks");
711
+ var HOOKS_DIR = path4.join(os3.homedir(), ".task", "hooks");
289
712
  var commands = {
290
713
  add: addBackend,
291
714
  install,
292
715
  sync,
716
+ timewarrior: timewarriorCmd,
293
717
  which,
294
718
  config: showConfig
295
719
  };
@@ -300,9 +724,10 @@ async function main() {
300
724
  console.log("Commands:");
301
725
  console.log(" add Add a new backend instance");
302
726
  console.log(" install Install Taskwarrior hooks and shell integration");
303
- console.log(" sync Pull tasks from all backends");
304
- console.log(" which Print the context for the current directory");
305
- console.log(" config Show current configuration");
727
+ console.log(" sync Pull tasks from all backends");
728
+ console.log(" timewarrior Manage Timewarrior integration");
729
+ console.log(" which Print the context for the current directory");
730
+ console.log(" config Show current configuration");
306
731
  return;
307
732
  }
308
733
  const handler = commands[command];
@@ -344,14 +769,23 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
344
769
  }
345
770
  const matchTag = tagOverride ?? name;
346
771
  const cwd = process.cwd();
347
- const adapterConfig = adapter.defaultConfig(cwd);
772
+ const adapterConfig = adapter.setup ? await adapter.setup(cwd) : adapter.defaultConfig(cwd);
773
+ let doneStatuses;
774
+ const sections = adapterConfig.sections;
775
+ const doneSectionGids = adapterConfig.done_sections;
776
+ if (sections?.length && doneSectionGids?.length) {
777
+ const gidSet = new Set(doneSectionGids);
778
+ doneStatuses = sections.filter((s) => gidSet.has(s.gid)).map((s) => s.tag);
779
+ }
348
780
  config.backends[name] = {
349
781
  adapter: adapterType,
350
782
  match: { tags: [matchTag] },
783
+ ...doneStatuses?.length && { done_statuses: doneStatuses },
351
784
  config: adapterConfig
352
785
  };
353
786
  const configPath = saveConfig(config);
354
- console.log(`Added backend "${name}" (adapter: ${adapterType})`);
787
+ console.log(`
788
+ Added backend "${name}" (adapter: ${adapterType})`);
355
789
  console.log(`Config: ${configPath}`);
356
790
  console.log(`Match tag: +${matchTag}`);
357
791
  if (adapterConfig.cwd) {
@@ -366,18 +800,18 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
366
800
  Use 'task context ${matchTag}' to switch to this project.`);
367
801
  }
368
802
  async function install() {
369
- fs2.mkdirSync(HOOKS_DIR, { recursive: true });
370
- const hookSource = path3.resolve(
371
- path3.dirname(new URL(import.meta.url).pathname),
803
+ fs3.mkdirSync(HOOKS_DIR, { recursive: true });
804
+ const hookSource = path4.resolve(
805
+ path4.dirname(new URL(import.meta.url).pathname),
372
806
  "hooks",
373
807
  "on-modify.js"
374
808
  );
375
- const hookTarget = path3.join(HOOKS_DIR, "on-modify.tw-bridge");
376
- if (fs2.existsSync(hookTarget)) {
377
- fs2.unlinkSync(hookTarget);
809
+ const hookTarget = path4.join(HOOKS_DIR, "on-modify.tw-bridge");
810
+ if (fs3.existsSync(hookTarget)) {
811
+ fs3.unlinkSync(hookTarget);
378
812
  }
379
- fs2.symlinkSync(hookSource, hookTarget);
380
- fs2.chmodSync(hookSource, 493);
813
+ fs3.symlinkSync(hookSource, hookTarget);
814
+ fs3.chmodSync(hookSource, 493);
381
815
  console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
382
816
  console.log("\nAdd these to your .taskrc:\n");
383
817
  console.log("# --- tw-bridge UDAs ---");
@@ -393,51 +827,141 @@ async function install() {
393
827
  console.log("urgency.user.tag.in_review.coefficient=-2.0");
394
828
  console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
395
829
  console.log("urgency.user.tag.in_beta.coefficient=-6.0");
396
- const timewHook = path3.join(HOOKS_DIR, "on-modify.timewarrior");
397
- if (fs2.existsSync(timewHook)) {
398
- console.log("\nNote: Found on-modify.timewarrior hook.");
399
- console.log("If you enable tw-bridge Timewarrior management, disable it to avoid double-tracking:");
400
- console.log(` mv ${timewHook} ${timewHook}.disabled`);
830
+ installTimewExtension();
831
+ if (fs3.existsSync(STANDARD_TIMEW_HOOK)) {
832
+ console.log("\nTimewarrior hook detected. To enable tw-bridge time tracking:");
833
+ console.log(" tw-bridge timewarrior enable");
401
834
  }
402
835
  installShellFunction();
403
836
  }
837
+ var TIMEW_EXT_DIR = path4.join(os3.homedir(), ".timewarrior", "extensions");
838
+ function installTimewExtension() {
839
+ fs3.mkdirSync(TIMEW_EXT_DIR, { recursive: true });
840
+ const extSource = path4.resolve(
841
+ path4.dirname(new URL(import.meta.url).pathname),
842
+ "extensions",
843
+ "bridge.js"
844
+ );
845
+ const extTarget = path4.join(TIMEW_EXT_DIR, "bridge");
846
+ if (fs3.existsSync(extTarget)) {
847
+ fs3.unlinkSync(extTarget);
848
+ }
849
+ fs3.symlinkSync(extSource, extTarget);
850
+ fs3.chmodSync(extSource, 493);
851
+ console.log(`
852
+ Installed Timewarrior extension: ${extTarget} -> ${extSource}`);
853
+ console.log(" Usage: timew bridge [task-time|wall-time] [project-filter]");
854
+ }
855
+ var STANDARD_TIMEW_HOOK = path4.join(HOOKS_DIR, "on-modify.timewarrior");
856
+ async function timewarriorCmd() {
857
+ const sub = process.argv[3];
858
+ if (!sub || sub === "--help") {
859
+ console.log("Usage: tw-bridge timewarrior <subcommand>\n");
860
+ console.log("Subcommands:");
861
+ console.log(" enable Enable Timewarrior tracking");
862
+ console.log(" disable Disable Timewarrior tracking");
863
+ console.log(" status Show current Timewarrior configuration");
864
+ console.log("\nParallel time tracking is controlled per-invocation:");
865
+ console.log(" task start <id> --parallel Track in parallel with current task");
866
+ console.log(" task start <id> --switch Stop current task, start new one");
867
+ return;
868
+ }
869
+ const config = loadConfig();
870
+ if (sub === "status") {
871
+ const tw = config.timewarrior;
872
+ if (!tw?.enabled) {
873
+ console.log("Timewarrior: disabled");
874
+ } else {
875
+ console.log("Timewarrior: enabled");
876
+ console.log(" Parallel tracking: use `task start <id> --parallel`");
877
+ }
878
+ const hookExists = fs3.existsSync(STANDARD_TIMEW_HOOK);
879
+ const hookDisabled = fs3.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
880
+ if (hookExists) {
881
+ console.log(`Standard hook: active (${STANDARD_TIMEW_HOOK})`);
882
+ if (tw?.enabled) {
883
+ console.log(" Warning: may cause double-tracking. Run `tw-bridge timewarrior enable` to fix.");
884
+ }
885
+ } else if (hookDisabled) {
886
+ console.log("Standard hook: disabled");
887
+ } else {
888
+ console.log("Standard hook: not found");
889
+ }
890
+ return;
891
+ }
892
+ if (sub === "enable") {
893
+ config.timewarrior = { enabled: true };
894
+ const configPath = saveConfig(config);
895
+ console.log("Timewarrior tracking enabled");
896
+ console.log(`Config: ${configPath}`);
897
+ if (fs3.existsSync(STANDARD_TIMEW_HOOK)) {
898
+ const disabled = STANDARD_TIMEW_HOOK + ".disabled";
899
+ fs3.renameSync(STANDARD_TIMEW_HOOK, disabled);
900
+ console.log(`
901
+ Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
902
+ console.log("tw-bridge will handle Timewarrior tracking directly.");
903
+ }
904
+ return;
905
+ }
906
+ if (sub === "disable") {
907
+ config.timewarrior = { enabled: false };
908
+ const configPath = saveConfig(config);
909
+ console.log("Timewarrior tracking disabled");
910
+ console.log(`Config: ${configPath}`);
911
+ const disabled = STANDARD_TIMEW_HOOK + ".disabled";
912
+ if (fs3.existsSync(disabled)) {
913
+ fs3.renameSync(disabled, STANDARD_TIMEW_HOOK);
914
+ console.log(`
915
+ Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
916
+ }
917
+ return;
918
+ }
919
+ console.error(`Unknown subcommand: ${sub}`);
920
+ process.exit(1);
921
+ }
404
922
  var SHELL_FUNCTION = `
405
923
  # tw-bridge: auto-context task wrapper
406
924
  task() {
407
- local ctx
925
+ local ctx mode=""
926
+ local args=()
927
+
928
+ for arg in "$@"; do
929
+ case "$arg" in
930
+ --parallel|-p) mode="parallel" ;;
931
+ --switch|-s) mode="switch" ;;
932
+ *) args+=("$arg") ;;
933
+ esac
934
+ done
935
+
408
936
  ctx=$(tw-bridge which 2>/dev/null)
409
- if [ -n "$ctx" ]; then
410
- command task "rc.context=$ctx" "$@"
411
- else
412
- command task "$@"
413
- fi
937
+ TW_BRIDGE_MODE="$mode" command task \${ctx:+"rc.context=$ctx"} "\${args[@]}"
414
938
  }
415
939
  `.trim();
416
940
  var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
417
941
  function installShellFunction() {
418
942
  const shell = process.env.SHELL ?? "/bin/bash";
419
- const home = os2.homedir();
943
+ const home = os3.homedir();
420
944
  let rcFile;
421
945
  if (shell.endsWith("zsh")) {
422
- rcFile = path3.join(home, ".zshrc");
946
+ rcFile = path4.join(home, ".zshrc");
423
947
  } else {
424
- rcFile = path3.join(home, ".bashrc");
948
+ rcFile = path4.join(home, ".bashrc");
425
949
  }
426
- const existing = fs2.existsSync(rcFile) ? fs2.readFileSync(rcFile, "utf-8") : "";
950
+ const existing = fs3.existsSync(rcFile) ? fs3.readFileSync(rcFile, "utf-8") : "";
427
951
  if (existing.includes(SHELL_MARKER)) {
428
952
  console.log(`
429
953
  Shell integration already installed in ${rcFile}`);
430
954
  return;
431
955
  }
432
- fs2.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
956
+ fs3.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
433
957
  console.log(`
434
958
  Shell integration installed in ${rcFile}`);
435
959
  console.log("Restart your shell or run: source " + rcFile);
436
960
  }
437
- var SEEN_FILE = path3.join(os2.homedir(), ".config", "tw-bridge", ".seen-dirs");
961
+ var SEEN_FILE = path4.join(os3.homedir(), ".config", "tw-bridge", ".seen-dirs");
438
962
  function loadSeenDirs() {
439
963
  try {
440
- const raw = fs2.readFileSync(SEEN_FILE, "utf-8");
964
+ const raw = fs3.readFileSync(SEEN_FILE, "utf-8");
441
965
  return new Set(raw.split("\n").filter(Boolean));
442
966
  } catch {
443
967
  return /* @__PURE__ */ new Set();
@@ -447,15 +971,15 @@ function markDirSeen(dir) {
447
971
  const seen = loadSeenDirs();
448
972
  if (seen.has(dir)) return;
449
973
  seen.add(dir);
450
- fs2.mkdirSync(path3.dirname(SEEN_FILE), { recursive: true });
451
- fs2.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
974
+ fs3.mkdirSync(path4.dirname(SEEN_FILE), { recursive: true });
975
+ fs3.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
452
976
  }
453
977
  function isGitRepo(dir) {
454
978
  try {
455
979
  let current = dir;
456
- while (current !== path3.dirname(current)) {
457
- if (fs2.existsSync(path3.join(current, ".git"))) return true;
458
- current = path3.dirname(current);
980
+ while (current !== path4.dirname(current)) {
981
+ if (fs3.existsSync(path4.join(current, ".git"))) return true;
982
+ current = path4.dirname(current);
459
983
  }
460
984
  return false;
461
985
  } catch {
@@ -468,8 +992,8 @@ async function which() {
468
992
  for (const [_name, backend] of Object.entries(config.backends)) {
469
993
  const backendCwd = backend.config?.cwd;
470
994
  if (!backendCwd) continue;
471
- const resolved = path3.resolve(backendCwd);
472
- if (cwd === resolved || cwd.startsWith(resolved + path3.sep)) {
995
+ const resolved = path4.resolve(backendCwd);
996
+ if (cwd === resolved || cwd.startsWith(resolved + path4.sep)) {
473
997
  const contextTag = backend.match.tags?.[0];
474
998
  if (contextTag) {
475
999
  process.stdout.write(contextTag);
@@ -485,15 +1009,15 @@ async function which() {
485
1009
  const seen = loadSeenDirs();
486
1010
  let gitRoot = cwd;
487
1011
  let current = cwd;
488
- while (current !== path3.dirname(current)) {
489
- if (fs2.existsSync(path3.join(current, ".git"))) {
1012
+ while (current !== path4.dirname(current)) {
1013
+ if (fs3.existsSync(path4.join(current, ".git"))) {
490
1014
  gitRoot = current;
491
1015
  break;
492
1016
  }
493
- current = path3.dirname(current);
1017
+ current = path4.dirname(current);
494
1018
  }
495
1019
  if (!seen.has(gitRoot)) {
496
- const dirName = path3.basename(gitRoot);
1020
+ const dirName = path4.basename(gitRoot);
497
1021
  process.stderr.write(
498
1022
  `tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
499
1023
  `
@@ -557,6 +1081,14 @@ async function syncBackend(name, backend, config) {
557
1081
  }
558
1082
  } else if (isDone) {
559
1083
  if (completeTask(existingTask, matchTags)) {
1084
+ if (adapter.onDone) {
1085
+ try {
1086
+ await adapter.onDone(task);
1087
+ } catch (err) {
1088
+ const msg = err instanceof Error ? err.message : String(err);
1089
+ console.error(` \u26A0 [#${backendId}] failed to push completion: ${msg}`);
1090
+ }
1091
+ }
560
1092
  console.log(` \u2713 [#${backendId}] ${task.description}`);
561
1093
  completed++;
562
1094
  }