@tomkapa/tayto 0.3.2 → 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.
@@ -143,8 +143,46 @@ function isTerminalStatus(status) {
143
143
  return TERMINAL_STATUSES.has(status);
144
144
  }
145
145
 
146
+ // src/utils/git.ts
147
+ import { spawnSync } from "child_process";
148
+
149
+ // src/types/common.ts
150
+ function ok(value) {
151
+ return { ok: true, value };
152
+ }
153
+ function err(error) {
154
+ return { ok: false, error };
155
+ }
156
+
157
+ // src/utils/git.ts
158
+ function detectGitRemote(cwd) {
159
+ try {
160
+ const result = spawnSync("git", ["remote", "get-url", "origin"], {
161
+ cwd: cwd ?? process.cwd(),
162
+ encoding: "utf-8",
163
+ timeout: 5e3
164
+ });
165
+ if (result.status !== 0 || result.error) {
166
+ logger.info("detectGitRemote: no git remote found (non-zero exit or error)");
167
+ return ok(null);
168
+ }
169
+ const remote = result.stdout.trim();
170
+ if (!remote) {
171
+ logger.info("detectGitRemote: empty stdout from git remote get-url");
172
+ return ok(null);
173
+ }
174
+ logger.info(`detectGitRemote: found remote=${remote}`);
175
+ return ok(remote);
176
+ } catch (e) {
177
+ logger.info(`detectGitRemote: exception during git detection: ${String(e)}`);
178
+ return ok(null);
179
+ }
180
+ }
181
+
146
182
  export {
147
183
  logger,
184
+ ok,
185
+ err,
148
186
  TaskStatus,
149
187
  TaskType,
150
188
  TaskLevel,
@@ -155,6 +193,7 @@ export {
155
193
  RANK_GAP,
156
194
  midpoint,
157
195
  TERMINAL_STATUSES,
158
- isTerminalStatus
196
+ isTerminalStatus,
197
+ detectGitRemote
159
198
  };
160
- //# sourceMappingURL=chunk-STYT4TGJ.js.map
199
+ //# sourceMappingURL=chunk-74Q55TOV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/logging/logger.ts","../src/types/enums.ts","../src/utils/git.ts","../src/types/common.ts"],"sourcesContent":["import { appendFileSync, readdirSync, unlinkSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { trace, type Span, SpanStatusCode } from '@opentelemetry/api';\n\nconst tracer = trace.getTracer('task');\nconst LOG_RETENTION_DAYS = 7;\n\nexport interface LogAttributes {\n [key: string]: string | number | boolean;\n}\n\ntype LogLevel = 'INFO' | 'WARN' | 'ERROR';\n\nfunction formatTimestamp(): string {\n return new Date().toISOString();\n}\n\nfunction formatAttrs(attrs?: LogAttributes): string {\n if (!attrs || Object.keys(attrs).length === 0) return '';\n return ' ' + JSON.stringify(attrs);\n}\n\nclass Logger {\n private logFilePath: string | null = null;\n\n init(logDir: string): void {\n const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD\n this.logFilePath = join(logDir, `task-${date}.log`);\n this.pruneOldLogs(logDir);\n }\n\n info(message: string, attrs?: LogAttributes): void {\n this.write('INFO', message, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(message, attrs);\n }\n }\n\n warn(message: string, attrs?: LogAttributes): void {\n this.write('WARN', message, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(`WARN: ${message}`, attrs);\n }\n }\n\n error(message: string, error?: unknown, attrs?: LogAttributes): void {\n const errorDetail = error instanceof Error ? ` | ${error.stack ?? error.message}` : '';\n this.write('ERROR', `${message}${errorDetail}`, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(`ERROR: ${message}`, attrs);\n if (error instanceof Error) {\n span.recordException(error);\n }\n span.setStatus({ code: SpanStatusCode.ERROR, message });\n }\n }\n\n startSpan<T>(name: string, fn: (span: Span) => T): T {\n return tracer.startActiveSpan(name, (span) => {\n try {\n const result = fn(span);\n span.end();\n return result;\n } catch (e) {\n if (e instanceof Error) {\n span.recordException(e);\n }\n span.setStatus({ code: SpanStatusCode.ERROR });\n span.end();\n throw e;\n }\n });\n }\n\n private write(level: LogLevel, message: string, attrs?: LogAttributes): void {\n if (!this.logFilePath) return;\n const line = `${formatTimestamp()} [${level}] ${message}${formatAttrs(attrs)}\\n`;\n try {\n appendFileSync(this.logFilePath, line);\n } catch {\n // Swallowing here is intentional: logging must never crash the app.\n // If the log file is unwritable, the OTel span still captures the event.\n }\n }\n\n private pruneOldLogs(logDir: string): void {\n try {\n const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n const files = readdirSync(logDir).filter((f) => f.startsWith('task-') && f.endsWith('.log'));\n for (const file of files) {\n const dateStr = file.slice('task-'.length, -'.log'.length);\n const fileDate = new Date(dateStr).getTime();\n if (!isNaN(fileDate) && fileDate < cutoff) {\n unlinkSync(join(logDir, file));\n }\n }\n } catch {\n // Best-effort cleanup — don't crash if pruning fails\n }\n }\n}\n\nexport const logger = new Logger();\n","export const TaskStatus = {\n Backlog: 'backlog',\n Todo: 'todo',\n InProgress: 'in-progress',\n Review: 'review',\n Done: 'done',\n Cancelled: 'cancelled',\n} as const;\nexport type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus];\n\nexport const TaskType = {\n Epic: 'epic',\n Story: 'story',\n TechDebt: 'tech-debt',\n Bug: 'bug',\n} as const;\nexport type TaskType = (typeof TaskType)[keyof typeof TaskType];\n\n/**\n * Task level derived from type.\n * Level 1: epics (grouping/planning layer)\n * Level 2: stories, tech-debt, bugs (execution layer)\n */\nexport const TaskLevel = {\n Epic: 1,\n Work: 2,\n} as const;\nexport type TaskLevel = (typeof TaskLevel)[keyof typeof TaskLevel];\n\nconst TYPE_TO_LEVEL: Record<string, TaskLevel> = {\n [TaskType.Epic]: TaskLevel.Epic,\n [TaskType.Story]: TaskLevel.Work,\n [TaskType.TechDebt]: TaskLevel.Work,\n [TaskType.Bug]: TaskLevel.Work,\n};\n\nexport function getTaskLevel(type: string): TaskLevel {\n return TYPE_TO_LEVEL[type] ?? TaskLevel.Work;\n}\n\n/** Types that belong to the work (level 2) execution layer. */\nexport const WORK_TYPES: ReadonlySet<string> = new Set([\n TaskType.Story,\n TaskType.TechDebt,\n TaskType.Bug,\n]);\n\n/** Types stored in the database. */\nexport const DependencyType = {\n Blocks: 'blocks',\n RelatesTo: 'relates-to',\n Duplicates: 'duplicates',\n} as const;\nexport type DependencyType = (typeof DependencyType)[keyof typeof DependencyType];\n\n/**\n * UI-level dependency types — includes BlockedBy which is a reverse-Blocks\n * relationship resolved before persisting to the database.\n */\nexport const UIDependencyType = {\n ...DependencyType,\n BlockedBy: 'blocked-by',\n} as const;\nexport type UIDependencyType = (typeof UIDependencyType)[keyof typeof UIDependencyType];\n\n/** Gap between consecutive rank values, used for insertion between neighbors. */\nexport const RANK_GAP = 1000.0;\n\n/**\n * Collapse-safe midpoint between two rank values. Returns `null` when\n * IEEE 754 double precision cannot represent a strictly-between value —\n * callers use this as a signal to rebalance the rank grid and retry.\n * Returning a number unconditionally would silently collide with an\n * endpoint and corrupt the task ordering.\n */\nexport function midpoint(a: number, b: number): number | null {\n const m = (a + b) / 2;\n return m > a && m < b ? m : null;\n}\n\n/** Statuses that represent terminal/completed task states. */\nexport const TERMINAL_STATUSES: ReadonlySet<string> = new Set([\n TaskStatus.Done,\n TaskStatus.Cancelled,\n]);\n\nexport function isTerminalStatus(status: string): boolean {\n return TERMINAL_STATUSES.has(status);\n}\n","import { spawnSync } from 'node:child_process';\nimport type { Result } from '../types/common.js';\nimport { ok } from '../types/common.js';\nimport { logger } from '../logging/logger.js';\n\n/**\n * Detect the git origin remote URL from the given directory.\n * Returns ok(url) if found, ok(null) if no git repo or no origin remote.\n * Never returns err() — all git failures are silent fallbacks.\n */\nexport function detectGitRemote(cwd?: string): Result<string | null> {\n try {\n const result = spawnSync('git', ['remote', 'get-url', 'origin'], {\n cwd: cwd ?? process.cwd(),\n encoding: 'utf-8',\n timeout: 5000,\n });\n\n if (result.status !== 0 || result.error) {\n logger.info('detectGitRemote: no git remote found (non-zero exit or error)');\n return ok(null);\n }\n\n const remote = result.stdout.trim();\n if (!remote) {\n logger.info('detectGitRemote: empty stdout from git remote get-url');\n return ok(null);\n }\n\n logger.info(`detectGitRemote: found remote=${remote}`);\n return ok(remote);\n } catch (e: unknown) {\n logger.info(`detectGitRemote: exception during git detection: ${String(e)}`);\n return ok(null);\n }\n}\n","import type { AppError } from '../errors/app-error.js';\n\nexport type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };\n\nexport function ok<T>(value: T): Result<T, never> {\n return { ok: true, value };\n}\n\nexport function err<E>(error: E): Result<never, E> {\n return { ok: false, error };\n}\n\nexport interface CLIOutput<T> {\n ok: boolean;\n data?: T;\n error?: { code: string; message: string };\n}\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -8,11 +8,14 @@ import {
8
8
  TaskType,
9
9
  UIDependencyType,
10
10
  WORK_TYPES,
11
+ detectGitRemote,
12
+ err,
11
13
  getTaskLevel,
12
14
  isTerminalStatus,
13
15
  logger,
14
- midpoint
15
- } from "./chunk-STYT4TGJ.js";
16
+ midpoint,
17
+ ok
18
+ } from "./chunk-74Q55TOV.js";
16
19
 
17
20
  // src/config/index.ts
18
21
  import { mkdirSync } from "fs";
@@ -106,14 +109,6 @@ async function shutdownTelemetry() {
106
109
  }
107
110
  }
108
111
 
109
- // src/types/common.ts
110
- function ok(value) {
111
- return { ok: true, value };
112
- }
113
- function err(error) {
114
- return { ok: false, error };
115
- }
116
-
117
112
  // src/errors/app-error.ts
118
113
  var AppError = class extends Error {
119
114
  constructor(code, message, cause) {
@@ -156,6 +151,7 @@ function rowToProject(row) {
156
151
  name: row.name,
157
152
  description: row.description,
158
153
  isDefault: row.is_default === 1,
154
+ gitRemote: row.git_remote,
159
155
  createdAt: row.created_at,
160
156
  updatedAt: row.updated_at
161
157
  };
@@ -174,14 +170,15 @@ var SqliteProjectRepository = class {
174
170
  this.db.prepare("UPDATE projects SET is_default = 0 WHERE is_default = 1").run();
175
171
  }
176
172
  this.db.prepare(
177
- `INSERT INTO projects (id, key, name, description, is_default, created_at, updated_at)
178
- VALUES (?, ?, ?, ?, ?, ?, ?)`
173
+ `INSERT INTO projects (id, key, name, description, is_default, git_remote, created_at, updated_at)
174
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
179
175
  ).run(
180
176
  id,
181
177
  input.key,
182
178
  input.name,
183
179
  input.description ?? "",
184
180
  input.isDefault ? 1 : 0,
181
+ input.gitRemote ?? null,
185
182
  now,
186
183
  now
187
184
  );
@@ -192,6 +189,15 @@ var SqliteProjectRepository = class {
192
189
  return ok(rowToProject(row));
193
190
  } catch (e) {
194
191
  if (e instanceof Error && e.message.includes("UNIQUE constraint")) {
192
+ if (e.message.includes("git_remote")) {
193
+ return err(
194
+ new AppError(
195
+ "DUPLICATE",
196
+ `Git remote already linked to another project: ${input.gitRemote}`,
197
+ e
198
+ )
199
+ );
200
+ }
195
201
  return err(new AppError("DUPLICATE", `Project name already exists: ${input.name}`, e));
196
202
  }
197
203
  return err(new AppError("DB_ERROR", "Failed to insert project", e));
@@ -222,6 +228,14 @@ var SqliteProjectRepository = class {
222
228
  return err(new AppError("DB_ERROR", "Failed to find project by name", e));
223
229
  }
224
230
  }
231
+ findByGitRemote(remote) {
232
+ try {
233
+ const row = this.db.prepare(`SELECT * FROM projects WHERE git_remote = ? AND ${NOT_DELETED}`).get(remote);
234
+ return ok(row ? rowToProject(row) : null);
235
+ } catch (e) {
236
+ return err(new AppError("DB_ERROR", "Failed to find project by git remote", e));
237
+ }
238
+ }
225
239
  findDefault() {
226
240
  try {
227
241
  const row = this.db.prepare(`SELECT * FROM projects WHERE is_default = 1 AND ${NOT_DELETED}`).get();
@@ -251,12 +265,13 @@ var SqliteProjectRepository = class {
251
265
  }
252
266
  this.db.prepare(
253
267
  `UPDATE projects SET
254
- name = ?, description = ?, is_default = ?, updated_at = ?
268
+ name = ?, description = ?, is_default = ?, git_remote = ?, updated_at = ?
255
269
  WHERE id = ?`
256
270
  ).run(
257
271
  input.name ?? existing.name,
258
272
  input.description ?? existing.description,
259
273
  input.isDefault !== void 0 ? input.isDefault ? 1 : 0 : existing.is_default,
274
+ input.gitRemote !== void 0 ? input.gitRemote : existing.git_remote,
260
275
  now,
261
276
  id
262
277
  );
@@ -267,6 +282,11 @@ var SqliteProjectRepository = class {
267
282
  return ok(rowToProject(row));
268
283
  } catch (e) {
269
284
  if (e instanceof Error && e.message.includes("UNIQUE constraint")) {
285
+ if (e.message.includes("git_remote")) {
286
+ return err(
287
+ new AppError("DUPLICATE", `Git remote already linked to another project`, e)
288
+ );
289
+ }
270
290
  return err(new AppError("DUPLICATE", `Project name already exists`, e));
271
291
  }
272
292
  return err(new AppError("DB_ERROR", "Failed to update project", e));
@@ -942,20 +962,24 @@ var CreateProjectSchema = z.object({
942
962
  name: z.string().min(1, "Project name is required").max(255),
943
963
  key: z.string().min(2, "Project key must be at least 2 characters").max(7, "Project key must be at most 7 characters").regex(/^[A-Za-z0-9]+$/, "Project key must contain only letters and digits").transform((v) => v.toUpperCase()).optional(),
944
964
  description: z.string().max(5e3).optional(),
945
- isDefault: z.boolean().optional()
965
+ isDefault: z.boolean().optional(),
966
+ gitRemote: z.string().min(1, "Git remote URL must not be empty").nullable().optional()
946
967
  });
947
968
  var UpdateProjectSchema = z.object({
948
969
  name: z.string().min(1).max(255).optional(),
949
970
  description: z.string().max(5e3).optional(),
950
- isDefault: z.boolean().optional()
971
+ isDefault: z.boolean().optional(),
972
+ gitRemote: z.string().min(1, "Git remote URL must not be empty").nullable().optional()
951
973
  });
952
974
 
953
975
  // src/service/project.service.ts
954
976
  var ProjectServiceImpl = class {
955
- constructor(repo) {
977
+ constructor(repo, detectRemote = detectGitRemote) {
956
978
  this.repo = repo;
979
+ this.detectRemote = detectRemote;
957
980
  }
958
981
  repo;
982
+ detectRemote;
959
983
  createProject(input) {
960
984
  return logger.startSpan("ProjectService.createProject", () => {
961
985
  const parsed = CreateProjectSchema.safeParse(input);
@@ -1037,6 +1061,58 @@ var ProjectServiceImpl = class {
1037
1061
  );
1038
1062
  });
1039
1063
  }
1064
+ resolveProjectWithGit(idOrName, cwd) {
1065
+ return logger.startSpan("ProjectService.resolveProjectWithGit", () => {
1066
+ if (idOrName) {
1067
+ return this.resolveProject(idOrName);
1068
+ }
1069
+ const remoteResult = this.detectRemote(cwd);
1070
+ if (remoteResult.ok && remoteResult.value) {
1071
+ const byRemote = this.repo.findByGitRemote(remoteResult.value);
1072
+ if (!byRemote.ok) return byRemote;
1073
+ if (byRemote.value) {
1074
+ logger.info(
1075
+ `resolveProjectWithGit: matched git remote to project key=${byRemote.value.key}`
1076
+ );
1077
+ return ok(byRemote.value);
1078
+ }
1079
+ }
1080
+ return this.resolveProject();
1081
+ });
1082
+ }
1083
+ linkGitRemote(idOrName, remote) {
1084
+ return logger.startSpan("ProjectService.linkGitRemote", () => {
1085
+ const resolved = this.resolveProject(idOrName);
1086
+ if (!resolved.ok) return resolved;
1087
+ let url = remote;
1088
+ if (!url) {
1089
+ const detected = this.detectRemote();
1090
+ if (!detected.ok) return detected;
1091
+ if (!detected.value) {
1092
+ return err(
1093
+ new AppError(
1094
+ "NOT_FOUND",
1095
+ "No git remote detected in current directory. Use --remote <url> to specify one explicitly."
1096
+ )
1097
+ );
1098
+ }
1099
+ url = detected.value;
1100
+ }
1101
+ return this.repo.update(resolved.value.id, { gitRemote: url });
1102
+ });
1103
+ }
1104
+ unlinkGitRemote(idOrName) {
1105
+ return logger.startSpan("ProjectService.unlinkGitRemote", () => {
1106
+ const resolved = this.resolveProject(idOrName);
1107
+ if (!resolved.ok) return resolved;
1108
+ if (!resolved.value.gitRemote) {
1109
+ return err(
1110
+ new AppError("NOT_FOUND", `Project "${resolved.value.name}" has no linked git remote.`)
1111
+ );
1112
+ }
1113
+ return this.repo.update(resolved.value.id, { gitRemote: null });
1114
+ });
1115
+ }
1040
1116
  nextTaskId(project) {
1041
1117
  return logger.startSpan("ProjectService.nextTaskId", () => {
1042
1118
  const counterResult = this.repo.incrementTaskCounter(project.id);
@@ -1168,12 +1244,12 @@ var TaskServiceImpl = class {
1168
1244
  if (!parsed.success) {
1169
1245
  return err(new AppError("VALIDATION", parsed.error.message));
1170
1246
  }
1171
- let resolvedFilter = parsed.data;
1172
- if (parsed.data.projectId) {
1173
- const projectResult = this.projectService.resolveProject(parsed.data.projectId);
1174
- if (!projectResult.ok) return projectResult;
1175
- resolvedFilter = { ...resolvedFilter, projectId: projectResult.value.id };
1176
- }
1247
+ const projectResult = this.projectService.resolveProject(parsed.data.projectId);
1248
+ if (!projectResult.ok) return projectResult;
1249
+ const resolvedFilter = {
1250
+ ...parsed.data,
1251
+ projectId: projectResult.value.id
1252
+ };
1177
1253
  return this.repo.findMany(resolvedFilter);
1178
1254
  });
1179
1255
  }
@@ -1453,13 +1529,9 @@ var TaskServiceImpl = class {
1453
1529
  if (!query.trim()) {
1454
1530
  return err(new AppError("VALIDATION", "Search query cannot be empty"));
1455
1531
  }
1456
- let projectId;
1457
- if (projectIdOrName) {
1458
- const projectResult = this.projectService.resolveProject(projectIdOrName);
1459
- if (!projectResult.ok) return projectResult;
1460
- projectId = projectResult.value.id;
1461
- }
1462
- return this.repo.search(query, projectId);
1532
+ const projectResult = this.projectService.resolveProject(projectIdOrName);
1533
+ if (!projectResult.ok) return projectResult;
1534
+ return this.repo.search(query, projectResult.value.id);
1463
1535
  });
1464
1536
  }
1465
1537
  /**
@@ -1953,11 +2025,11 @@ var PortabilityServiceImpl = class {
1953
2025
  };
1954
2026
 
1955
2027
  // src/cli/container.ts
1956
- function createContainer(db, dbPath) {
2028
+ function createContainer(db, dbPath, detectGitRemote2) {
1957
2029
  const projectRepo = new SqliteProjectRepository(db);
1958
2030
  const taskRepo = new SqliteTaskRepository(db);
1959
2031
  const depRepo = new SqliteDependencyRepository(db);
1960
- const projectService = new ProjectServiceImpl(projectRepo);
2032
+ const projectService = new ProjectServiceImpl(projectRepo, detectGitRemote2);
1961
2033
  const dependencyService = new DependencyServiceImpl(depRepo, taskRepo);
1962
2034
  const taskService = new TaskServiceImpl(taskRepo, projectService, () => dependencyService);
1963
2035
  const portabilityService = new PortabilityServiceImpl(
@@ -1993,16 +2065,19 @@ function handleResult(result) {
1993
2065
  function registerProjectCreate(parent, container) {
1994
2066
  parent.command("create").description("Create a new project").requiredOption("-n, --name <name>", "Project name").option(
1995
2067
  "-k, --key <key>",
1996
- "Project key (2-10 alphanumeric chars, defaults to first 3 chars of name)"
1997
- ).option("-d, --description <description>", "Project description").option("--default", "Set as default project").action((opts) => {
1998
- const result = container.projectService.createProject({
1999
- name: opts.name,
2000
- key: opts.key,
2001
- description: opts.description,
2002
- isDefault: opts.default
2003
- });
2004
- handleResult(result);
2005
- });
2068
+ "Project key (2-7 uppercase alphanumeric chars, defaults to first 3 chars of name)"
2069
+ ).option("-d, --description <description>", "Project description").option("--default", "Set as default project").option("--git-remote <url>", "Git remote URL to associate with the project").action(
2070
+ (opts) => {
2071
+ const result = container.projectService.createProject({
2072
+ name: opts.name,
2073
+ key: opts.key,
2074
+ description: opts.description,
2075
+ isDefault: opts.default,
2076
+ gitRemote: opts.gitRemote
2077
+ });
2078
+ handleResult(result);
2079
+ }
2080
+ );
2006
2081
  }
2007
2082
 
2008
2083
  // src/cli/commands/project/list.ts
@@ -2060,6 +2135,22 @@ function registerProjectSetDefault(parent, container) {
2060
2135
  });
2061
2136
  }
2062
2137
 
2138
+ // src/cli/commands/project/link.ts
2139
+ function registerProjectLink(parent, container) {
2140
+ parent.command("link <idOrKeyOrName>").description("Link a project to a git remote (auto-detects from cwd if --remote omitted)").option("-r, --remote <url>", "Git remote URL").action((idOrKeyOrName, opts) => {
2141
+ const result = container.projectService.linkGitRemote(idOrKeyOrName, opts.remote);
2142
+ handleResult(result);
2143
+ });
2144
+ }
2145
+
2146
+ // src/cli/commands/project/unlink.ts
2147
+ function registerProjectUnlink(parent, container) {
2148
+ parent.command("unlink <idOrKeyOrName>").description("Remove git remote link from a project").action((idOrKeyOrName) => {
2149
+ const result = container.projectService.unlinkGitRemote(idOrKeyOrName);
2150
+ handleResult(result);
2151
+ });
2152
+ }
2153
+
2063
2154
  // src/cli/commands/task/create.ts
2064
2155
  function registerTaskCreate(parent, container) {
2065
2156
  parent.command("create").description("Create a new task (appended to bottom of backlog)").requiredOption("-n, --name <name>", "Task name").option("-p, --project <project>", "Project id or name").option("-d, --description <description>", "Task description").option("-t, --type <type>", "Task type: epic, story, tech-debt, bug", "story").option("-s, --status <status>", "Task status", "backlog").option("--parent <parentId>", "Parent task id for subtask").option("--technical-notes <notes>", "Technical notes (markdown)").option("--additional-requirements <requirements>", "Additional requirements (markdown)").option("--depends-on <ids...>", "Task ids this task depends on (blocks relationship)").action(
@@ -2302,6 +2393,8 @@ function buildCLI(container) {
2302
2393
  registerProjectUpdate(project, container);
2303
2394
  registerProjectDelete(project, container);
2304
2395
  registerProjectSetDefault(project, container);
2396
+ registerProjectLink(project, container);
2397
+ registerProjectUnlink(project, container);
2305
2398
  const task = program.command("task").description("Manage tasks");
2306
2399
  registerTaskCreate(task, container);
2307
2400
  registerTaskList(task, container);
@@ -2319,7 +2412,7 @@ function buildCLI(container) {
2319
2412
  registerDepList(dep, container);
2320
2413
  registerDepGraph(dep, container);
2321
2414
  program.command("tui").description("Launch interactive terminal UI").option("-p, --project <project>", "Start with specific project").action(async (opts) => {
2322
- const { launchTUI } = await import("./tui-JEP3F4JS.js");
2415
+ const { launchTUI } = await import("./tui-4GNIGMCK.js");
2323
2416
  await launchTUI(container, opts.project);
2324
2417
  });
2325
2418
  return program;
@@ -2335,7 +2428,7 @@ async function main() {
2335
2428
  const container = createContainer(db, config.dbPath);
2336
2429
  const args = process.argv.slice(2);
2337
2430
  if (args.length === 0) {
2338
- const { launchTUI } = await import("./tui-JEP3F4JS.js");
2431
+ const { launchTUI } = await import("./tui-4GNIGMCK.js");
2339
2432
  await launchTUI(container);
2340
2433
  } else {
2341
2434
  const program = buildCLI(container);