pm-skill 1.1.2 → 1.1.4

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/linear.d.ts CHANGED
@@ -34,6 +34,11 @@ export interface IssueDetail {
34
34
  }
35
35
  export declare function createIssue(client: LinearClient, opts: CreateIssueOpts): Promise<Issue>;
36
36
  export declare function updateIssue(client: LinearClient, id: string, input: Record<string, unknown>): Promise<void>;
37
+ export declare function deleteIssue(client: LinearClient, id: string): Promise<void>;
38
+ export declare function getTeamProjects(client: LinearClient, teamId: string): Promise<Array<{
39
+ id: string;
40
+ name: string;
41
+ }>>;
37
42
  export declare function getIssue(client: LinearClient, identifier: string): Promise<Issue>;
38
43
  export declare function getIssueDetail(client: LinearClient, identifier: string): Promise<IssueDetail>;
39
44
  export declare function createRelation(client: LinearClient, issueId: string, relatedIssueId: string, type: "blocks" | "related" | "similar"): Promise<void>;
package/dist/linear.js CHANGED
@@ -42,6 +42,14 @@ export async function createIssue(client, opts) {
42
42
  export async function updateIssue(client, id, input) {
43
43
  await client.updateIssue(id, input);
44
44
  }
45
+ export async function deleteIssue(client, id) {
46
+ await client.deleteIssue(id);
47
+ }
48
+ export async function getTeamProjects(client, teamId) {
49
+ const team = await client.team(teamId);
50
+ const conn = await team.projects();
51
+ return conn.nodes.map((p) => ({ id: p.id, name: p.name }));
52
+ }
45
53
  export async function getIssue(client, identifier) {
46
54
  try {
47
55
  return await client.issue(identifier);
package/dist/notion.d.ts CHANGED
@@ -32,3 +32,28 @@ export declare function searchPages(client: Client, query: string): Promise<Arra
32
32
  id: string;
33
33
  title: string;
34
34
  }>>;
35
+ /**
36
+ * Archive (delete) a Notion page by ID.
37
+ */
38
+ export declare function deletePage(client: Client, pageId: string): Promise<void>;
39
+ /**
40
+ * Extract Notion page ID from a Notion URL.
41
+ * Handles formats like: https://notion.so/abc123def456... or https://www.notion.so/workspace/Page-Title-abc123def456
42
+ */
43
+ export declare function extractNotionPageId(url: string): string | null;
44
+ /**
45
+ * Convert markdown to Notion blocks.
46
+ */
47
+ export declare function mdToBlocks(markdown: string): BlockObjectRequest[];
48
+ /**
49
+ * Create a Notion page from markdown content.
50
+ * Handles the 100-block-per-request API limit by chunking.
51
+ */
52
+ export declare function createPageFromMarkdown(client: Client, parentPageId: string, title: string, markdown: string): Promise<{
53
+ id: string;
54
+ url: string;
55
+ }>;
56
+ /**
57
+ * Clear all blocks from a page, then append new markdown content.
58
+ */
59
+ export declare function updatePageContent(client: Client, pageId: string, markdown: string): Promise<void>;
package/dist/notion.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Client } from "@notionhq/client";
2
+ import { markdownToBlocks } from "@tryfabric/martian";
2
3
  // ── Client ──
3
4
  let _client = null;
4
5
  export function getNotionClient(apiKey) {
@@ -223,3 +224,77 @@ export async function searchPages(client, query) {
223
224
  "(untitled)",
224
225
  }));
225
226
  }
227
+ // ── Page Deletion ──
228
+ /**
229
+ * Archive (delete) a Notion page by ID.
230
+ */
231
+ export async function deletePage(client, pageId) {
232
+ await client.pages.update({ page_id: pageId, archived: true });
233
+ }
234
+ /**
235
+ * Extract Notion page ID from a Notion URL.
236
+ * Handles formats like: https://notion.so/abc123def456... or https://www.notion.so/workspace/Page-Title-abc123def456
237
+ */
238
+ export function extractNotionPageId(url) {
239
+ const match = url.match(/([a-f0-9]{32})(?:\?|$)/);
240
+ if (match) {
241
+ const raw = match[1];
242
+ // Format as UUID
243
+ return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
244
+ }
245
+ return null;
246
+ }
247
+ // ── Markdown Upload ──
248
+ /**
249
+ * Convert markdown to Notion blocks.
250
+ */
251
+ export function mdToBlocks(markdown) {
252
+ return markdownToBlocks(markdown);
253
+ }
254
+ /**
255
+ * Create a Notion page from markdown content.
256
+ * Handles the 100-block-per-request API limit by chunking.
257
+ */
258
+ export async function createPageFromMarkdown(client, parentPageId, title, markdown) {
259
+ const blocks = mdToBlocks(markdown);
260
+ // First batch goes with page creation (max 100)
261
+ const firstBatch = blocks.slice(0, 100);
262
+ const rest = blocks.slice(100);
263
+ const response = await client.pages.create({
264
+ parent: { page_id: parentPageId },
265
+ properties: {
266
+ title: { title: [{ text: { content: title } }] },
267
+ },
268
+ children: firstBatch,
269
+ });
270
+ const pageId = response.id;
271
+ // Append remaining blocks in chunks of 100
272
+ for (let i = 0; i < rest.length; i += 100) {
273
+ await client.blocks.children.append({
274
+ block_id: pageId,
275
+ children: rest.slice(i, i + 100),
276
+ });
277
+ }
278
+ return {
279
+ id: pageId,
280
+ url: `https://notion.so/${pageId.replace(/-/g, "")}`,
281
+ };
282
+ }
283
+ /**
284
+ * Clear all blocks from a page, then append new markdown content.
285
+ */
286
+ export async function updatePageContent(client, pageId, markdown) {
287
+ // 1. Delete existing blocks
288
+ const existing = await client.blocks.children.list({ block_id: pageId });
289
+ for (const block of existing.results) {
290
+ await client.blocks.delete({ block_id: block.id });
291
+ }
292
+ // 2. Append new blocks
293
+ const blocks = mdToBlocks(markdown);
294
+ for (let i = 0; i < blocks.length; i += 100) {
295
+ await client.blocks.children.append({
296
+ block_id: pageId,
297
+ children: blocks.slice(i, i + 100),
298
+ });
299
+ }
300
+ }
package/dist/workflows.js CHANGED
@@ -4,8 +4,8 @@ import { existsSync, copyFileSync, mkdirSync, readFileSync } from "fs";
4
4
  import { resolve, dirname } from "path";
5
5
  import { validateEnv, writeEnvFile, PKG_ROOT } from "./env.js";
6
6
  import { loadConfig, getTemplate, resolvePriority, resolveSeverity, validateDocType, validateLabel, } from "./config.js";
7
- import { getLinearClient, validateLinearKey, createIssue, getIssue, getIssueDetail, createRelation, createAttachment, createLabel, getTeams, getTeamStates, getTeamLabels, resolveLabels, } from "./linear.js";
8
- import { getNotionClient, createTemplatedPage, createDatabaseEntry, validateNotionKey, searchPages, } from "./notion.js";
7
+ import { getLinearClient, validateLinearKey, createIssue, deleteIssue, getIssue, getIssueDetail, createRelation, createAttachment, createLabel, getTeams, getTeamStates, getTeamLabels, getTeamProjects, resolveLabels, } from "./linear.js";
8
+ import { getNotionClient, createTemplatedPage, createDatabaseEntry, validateNotionKey, searchPages, createPageFromMarkdown, updatePageContent, deletePage, extractNotionPageId, } from "./notion.js";
9
9
  // ── Init ──
10
10
  function copyBundledFile(srcName, destPath) {
11
11
  if (existsSync(destPath)) {
@@ -69,7 +69,27 @@ async function init(args) {
69
69
  throw new Error(`Team '${teamId}' not found. Run 'npx pm-skill init --linear-key <key>' to see available teams.`);
70
70
  }
71
71
  }
72
- // 3. Validate Notion key + auto-detect root page
72
+ // 3. Auto-detect project
73
+ let selectedProjectId = projectId;
74
+ if (!selectedProjectId) {
75
+ const projects = await getTeamProjects(client, selectedTeamId);
76
+ if (projects.length === 0) {
77
+ console.log(" No projects found — skipping project assignment");
78
+ }
79
+ else if (projects.length === 1) {
80
+ selectedProjectId = projects[0].id;
81
+ console.log(` Auto-selected project: "${projects[0].name}"`);
82
+ }
83
+ else {
84
+ console.log(`\n Available projects (${projects.length}):`);
85
+ for (const proj of projects) {
86
+ console.log(` ${proj.name} | ${proj.id}`);
87
+ }
88
+ selectedProjectId = projects[0].id;
89
+ console.log(` Using first project: "${projects[0].name}". Override with --project-id <id>`);
90
+ }
91
+ }
92
+ // 4. Validate Notion key + auto-detect root page
73
93
  let selectedNotionPage = notionPage;
74
94
  if (notionKey) {
75
95
  console.log("\n[Notion] Validating API key...");
@@ -103,8 +123,8 @@ async function init(args) {
103
123
  LINEAR_API_KEY: linearKey,
104
124
  LINEAR_DEFAULT_TEAM_ID: selectedTeamId,
105
125
  };
106
- if (projectId)
107
- envEntries.LINEAR_DEFAULT_PROJECT_ID = projectId;
126
+ if (selectedProjectId)
127
+ envEntries.LINEAR_DEFAULT_PROJECT_ID = selectedProjectId;
108
128
  if (notionKey)
109
129
  envEntries.NOTION_API_KEY = notionKey;
110
130
  if (selectedNotionPage)
@@ -240,6 +260,7 @@ async function startFeature(ctx, args) {
240
260
  await createAttachment(ctx.linear, issue.id, page.url, `${title} — PRD`);
241
261
  console.log(`[Link] Linear ↔ Notion linked`);
242
262
  console.log(`\n✅ Feature started: ${issue.identifier} | Notion: ${page.url}`);
263
+ console.log(` Notion Page ID: ${page.id} (use with --parent for sub-docs)`);
243
264
  }
244
265
  async function reportBug(ctx, args) {
245
266
  const title = args._[0];
@@ -373,6 +394,167 @@ async function get(ctx, args) {
373
394
  }
374
395
  }
375
396
  }
397
+ async function pushDoc(ctx, args) {
398
+ const identifier = args._[0];
399
+ const filePath = args._[1];
400
+ const content = args.content;
401
+ const title = args.title;
402
+ const parentPageId = args.parent;
403
+ if (!identifier || (!filePath && !content)) {
404
+ throw new Error("Usage: npx pm-skill push-doc <issue> <file.md> [--title T] [--parent P]\n" +
405
+ " npx pm-skill push-doc <issue> --title T --content \"# md\" [--parent P]");
406
+ }
407
+ if (!ctx.notion) {
408
+ throw new Error("Notion is not configured. Run 'npx pm-skill init' with --notion-key.");
409
+ }
410
+ const targetParent = parentPageId ?? ctx.env.NOTION_ROOT_PAGE_ID;
411
+ if (!targetParent) {
412
+ throw new Error("No Notion parent page. Set NOTION_ROOT_PAGE_ID or use --parent <page-id>.");
413
+ }
414
+ // Read markdown
415
+ let markdown;
416
+ if (filePath) {
417
+ if (!existsSync(filePath)) {
418
+ throw new Error(`File not found: ${filePath}`);
419
+ }
420
+ markdown = readFileSync(filePath, "utf-8");
421
+ }
422
+ else {
423
+ markdown = content;
424
+ }
425
+ // Determine title
426
+ const docTitle = title ?? (filePath ? filePath.replace(/^.*[\\/]/, "").replace(/\.md$/, "") : "Untitled");
427
+ // Get Linear issue for linking
428
+ const issue = await getIssue(ctx.linear, identifier);
429
+ // Create Notion page under specified parent
430
+ const page = await createPageFromMarkdown(ctx.notion, targetParent, docTitle, markdown);
431
+ console.log(`[Notion] Page created: "${docTitle}" — ${page.url}`);
432
+ console.log(`[Notion] Page ID: ${page.id}`);
433
+ // Link to Linear issue
434
+ await createAttachment(ctx.linear, issue.id, page.url, docTitle, "source-of-truth");
435
+ console.log(`[Link] Attached to ${issue.identifier}`);
436
+ console.log(`\n✅ Document pushed: ${issue.identifier} | ${page.url}`);
437
+ }
438
+ async function updateDoc(ctx, args) {
439
+ const pageId = args._[0];
440
+ const filePath = args._[1];
441
+ const content = args.content;
442
+ if (!pageId || (!filePath && !content)) {
443
+ throw new Error("Usage: npx pm-skill update-doc <page-id> <file.md>\n" +
444
+ " npx pm-skill update-doc <page-id> --content \"# Updated...\"");
445
+ }
446
+ if (!ctx.notion) {
447
+ throw new Error("Notion is not configured. Run 'npx pm-skill init' with --notion-key.");
448
+ }
449
+ let markdown;
450
+ if (filePath) {
451
+ if (!existsSync(filePath)) {
452
+ throw new Error(`File not found: ${filePath}`);
453
+ }
454
+ markdown = readFileSync(filePath, "utf-8");
455
+ }
456
+ else {
457
+ markdown = content;
458
+ }
459
+ await updatePageContent(ctx.notion, pageId, markdown);
460
+ console.log(`✅ Page updated: ${pageId}`);
461
+ }
462
+ async function createFolder(ctx, args) {
463
+ const folderName = args._[0];
464
+ const parentPageId = args.parent;
465
+ if (!folderName) {
466
+ throw new Error("Usage: npx pm-skill create-folder <name> [--parent <page-id>]");
467
+ }
468
+ if (!ctx.notion) {
469
+ throw new Error("Notion is not configured. Run 'npx pm-skill init' with --notion-key.");
470
+ }
471
+ const targetParent = parentPageId ?? ctx.env.NOTION_ROOT_PAGE_ID;
472
+ if (!targetParent) {
473
+ throw new Error("No Notion parent page. Set NOTION_ROOT_PAGE_ID or use --parent <page-id>.");
474
+ }
475
+ const response = await ctx.notion.pages.create({
476
+ parent: { page_id: targetParent },
477
+ properties: {
478
+ title: { title: [{ text: { content: folderName } }] },
479
+ },
480
+ children: [],
481
+ });
482
+ const pageId = response.id;
483
+ const url = `https://notion.so/${pageId.replace(/-/g, "")}`;
484
+ console.log(`✅ Folder created: "${folderName}"`);
485
+ console.log(` Page ID: ${pageId}`);
486
+ console.log(` URL: ${url}`);
487
+ console.log(`\nUse with: npx pm-skill push-doc <issue> <file> --parent ${pageId}`);
488
+ }
489
+ async function del(ctx, args) {
490
+ const identifiers = args._;
491
+ if (identifiers.length === 0) {
492
+ throw new Error("Usage: npx pm-skill delete <issue> [issue2 ...]");
493
+ }
494
+ for (const identifier of identifiers) {
495
+ const detail = await getIssueDetail(ctx.linear, identifier);
496
+ // Delete linked Notion pages
497
+ if (ctx.notion && detail.attachments.length > 0) {
498
+ for (const att of detail.attachments) {
499
+ if (att.url.includes("notion.so")) {
500
+ const pageId = extractNotionPageId(att.url);
501
+ if (pageId) {
502
+ try {
503
+ await deletePage(ctx.notion, pageId);
504
+ console.log(` [Notion] Deleted: ${att.title}`);
505
+ }
506
+ catch {
507
+ console.log(` [Notion] Could not delete: ${att.url} (may already be deleted or no access)`);
508
+ }
509
+ }
510
+ }
511
+ }
512
+ }
513
+ // Delete Linear issue
514
+ await deleteIssue(ctx.linear, detail.issue.id);
515
+ console.log(`✅ Deleted: ${detail.issue.identifier} (${detail.issue.title})`);
516
+ }
517
+ }
518
+ async function selectProject(args) {
519
+ // Load env manually — we need API key and team ID but not project ID
520
+ const { loadEnvFile, writeEnvFile } = await import("./env.js");
521
+ loadEnvFile();
522
+ const apiKey = process.env.LINEAR_API_KEY;
523
+ const teamId = process.env.LINEAR_DEFAULT_TEAM_ID;
524
+ if (!apiKey || !teamId) {
525
+ throw new Error("LINEAR_API_KEY and LINEAR_DEFAULT_TEAM_ID must be set. Run 'npx pm-skill init' first.");
526
+ }
527
+ const client = getLinearClient(apiKey);
528
+ const projects = await getTeamProjects(client, teamId);
529
+ if (projects.length === 0) {
530
+ console.log("No projects found for this team.");
531
+ return;
532
+ }
533
+ const projectIdArg = args._[0];
534
+ if (projectIdArg) {
535
+ // Direct selection by ID or name
536
+ const match = projects.find((p) => p.id === projectIdArg || p.name.toLowerCase() === projectIdArg.toLowerCase());
537
+ if (!match) {
538
+ console.log("Available projects:");
539
+ for (const p of projects) {
540
+ console.log(` ${p.name} | ${p.id}`);
541
+ }
542
+ throw new Error(`Project '${projectIdArg}' not found.`);
543
+ }
544
+ writeEnvFile(process.cwd(), { LINEAR_DEFAULT_PROJECT_ID: match.id });
545
+ console.log(`✅ Selected project: "${match.name}" (${match.id})`);
546
+ }
547
+ else {
548
+ // List projects with current marker
549
+ const currentId = process.env.LINEAR_DEFAULT_PROJECT_ID;
550
+ console.log("Available projects:");
551
+ for (const p of projects) {
552
+ const marker = p.id === currentId ? " ← current" : "";
553
+ console.log(` ${p.name} | ${p.id}${marker}`);
554
+ }
555
+ console.log(`\nUsage: npx pm-skill select-project "<name or id>"`);
556
+ }
557
+ }
376
558
  // ── Command Registry ──
377
559
  const COMMANDS = {
378
560
  setup: (ctx, args) => setup(ctx, args),
@@ -382,12 +564,16 @@ const COMMANDS = {
382
564
  relate,
383
565
  block,
384
566
  "attach-doc": attachDoc,
567
+ "push-doc": pushDoc,
568
+ "update-doc": updateDoc,
569
+ "create-folder": createFolder,
570
+ delete: del,
385
571
  get,
386
572
  };
387
573
  // ── Main ──
388
574
  async function main() {
389
575
  const args = minimist(process.argv.slice(2), {
390
- string: ["severity", "type", "url", "title", "linear-key", "notion-key", "team-id", "project-id", "notion-page"],
576
+ string: ["severity", "type", "url", "title", "content", "parent", "linear-key", "notion-key", "team-id", "project-id", "notion-page"],
391
577
  boolean: ["sync", "version"],
392
578
  alias: { s: "severity", t: "type" },
393
579
  });
@@ -408,6 +594,7 @@ Commands:
408
594
  init --linear-key K [--notion-key K]
409
595
  Initialize project (validates keys, creates .env, config.yml, SKILL.md, AGENTS.md)
410
596
  setup [--sync] Verify config & label matching (--sync creates missing labels)
597
+ select-project [name-or-id] List or switch Linear project
411
598
  start-feature <title> Start feature (Linear issue + Notion PRD)
412
599
  report-bug <title> [--severity S] File bug report (severity: urgent/high/medium/low)
413
600
  add-task <parent> <title> Add sub-task to an issue
@@ -416,17 +603,30 @@ Commands:
416
603
  attach-doc <issue> --url U --title T --type Y
417
604
  Attach document (type: source-of-truth/issue-tracking/domain-knowledge)
418
605
  get <issue> Show issue details
606
+ push-doc <issue> <file.md> [--title T] [--parent P]
607
+ Upload markdown to Notion + link to issue
608
+ push-doc <issue> --title T --content "# md" [--parent P]
609
+ Push content directly (for AI agents)
610
+ update-doc <page-id> <file.md> Replace Notion page content with markdown
611
+ update-doc <page-id> --content "# md"
612
+ Replace content directly
613
+ create-folder <name> [--parent P] Create Notion folder (returns page ID for --parent)
614
+ delete <issue> [issue2 ...] Delete issue(s) + linked Notion pages
419
615
  help Show this help
420
616
  --version Show version
421
617
 
422
618
  All config is per-project (CWD). Run 'npx pm-skill init' in each project.`);
423
619
  return;
424
620
  }
425
- // init runs independently — no env/config validation
621
+ // These commands run independently — no full env/config validation
426
622
  if (command === "init") {
427
623
  await init(args);
428
624
  return;
429
625
  }
626
+ if (command === "select-project") {
627
+ await selectProject(args);
628
+ return;
629
+ }
430
630
  const cmdFn = COMMANDS[command];
431
631
  if (!cmdFn) {
432
632
  const available = ["init", ...Object.keys(COMMANDS)].join(", ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pm-skill",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Structured project management CLI — Linear + Notion integration for AI coding assistants (Claude Code, Codex)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,14 +48,15 @@
48
48
  "dependencies": {
49
49
  "@linear/sdk": "^80",
50
50
  "@notionhq/client": "^5",
51
+ "@tryfabric/martian": "^1.2.4",
51
52
  "js-yaml": "^4",
52
53
  "minimist": "^1"
53
54
  },
54
55
  "devDependencies": {
55
- "typescript": "^5",
56
- "tsx": "^4",
57
56
  "@types/js-yaml": "^4",
58
57
  "@types/minimist": "^1",
59
- "@types/node": "^22"
58
+ "@types/node": "^22",
59
+ "tsx": "^4",
60
+ "typescript": "^5"
60
61
  }
61
62
  }