pm-skill 1.1.3 → 1.1.5

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/.env.example CHANGED
@@ -1,17 +1,25 @@
1
1
  # === Linear ===
2
- # Settings > API > Personal API Keys 에서 발급
2
+ # Get from: Linear > Settings > API > Personal API Keys
3
+ # 발급: Linear > Settings > API > Personal API Keys
3
4
  LINEAR_API_KEY=lin_api_xxxxxxxx
4
5
 
5
- # setup 커맨드로 확인 가능
6
+ # Discovered by `npx pm-skill init` (auto-detected)
7
+ # `npx pm-skill init`으로 자동 감지됨
6
8
  LINEAR_DEFAULT_TEAM_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
9
+
10
+ # Optional — discovered by `npx pm-skill init` or `select-project`
11
+ # 선택사항 — init 또는 select-project로 자동 감지
7
12
  LINEAR_DEFAULT_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
8
13
 
9
- # === Notion ===
10
- # https://www.notion.so/my-integrations 에서 발급
14
+ # === Notion (optional) ===
15
+ # Get from: https://www.notion.so/my-integrations > New integration
16
+ # 발급: https://www.notion.so/my-integrations > New integration
11
17
  NOTION_API_KEY=secret_xxxxxxxx
12
18
 
13
- # Notion에서 문서를 생성할 상위 페이지 ID
19
+ # Parent page where docs are created — discovered by `npx pm-skill init`
20
+ # 문서가 생성될 상위 페이지 — init으로 자동 감지
14
21
  NOTION_ROOT_PAGE_ID=xxxxxxxx
15
22
 
16
- # (선택) 버그 추적 DB ID 설정 report-bug가 DB 엔트리로 생성
23
+ # Optional bug tracking DB ID (if omitted, bugs use pages instead)
24
+ # 선택사항 — 버그 추적 DB ID (미설정 시 페이지로 생성)
17
25
  NOTION_BUG_DB_ID=xxxxxxxx
package/AGENTS.md CHANGED
@@ -6,6 +6,32 @@ This file provides instructions for AI coding assistants (Codex, Claude Code, et
6
6
 
7
7
  A structured project management CLI that integrates Linear (issue tracking) and Notion (documentation). It enforces a config-driven workflow where only pre-defined labels, templates, and severity levels are allowed.
8
8
 
9
+ ## Prerequisites
10
+
11
+ - Node.js 18 or higher (`node --version` to check)
12
+ - Linear API key (get from: Linear > Settings > API > Personal API Keys)
13
+ - Notion API key (optional, get from: https://www.notion.so/my-integrations)
14
+
15
+ ## First-Time Setup
16
+
17
+ If `.env` does not exist in the project root, run initialization first:
18
+
19
+ ```bash
20
+ # Step 1: Initialize (validates keys, creates .env, config.yml, SKILL.md, AGENTS.md)
21
+ npx pm-skill init --linear-key <LINEAR_API_KEY> --notion-key <NOTION_API_KEY>
22
+
23
+ # Step 2: Verify setup and label matching
24
+ npx pm-skill setup
25
+
26
+ # Step 3 (optional): Create missing labels in Linear
27
+ npx pm-skill setup --sync
28
+ ```
29
+
30
+ If init fails:
31
+ - **"Linear API key validation failed"** — check the key at Linear > Settings > API
32
+ - **"No pages shared"** — share a Notion page with the integration first (page menu > Connections)
33
+ - **Network error** — check internet connection and retry
34
+
9
35
  ## How to Run Commands
10
36
 
11
37
  ```bash
@@ -21,8 +47,22 @@ npx pm-skill setup
21
47
  npx pm-skill setup --sync # create missing labels in Linear
22
48
  ```
23
49
 
50
+ ### select-project
51
+ List or switch the active Linear project.
52
+ ```bash
53
+ npx pm-skill select-project # list projects
54
+ npx pm-skill select-project "Project Name" # switch
55
+ ```
56
+
57
+ ### select-page
58
+ List or switch the active Notion root page.
59
+ ```bash
60
+ npx pm-skill select-page # list pages
61
+ npx pm-skill select-page "Page Name" # switch
62
+ ```
63
+
24
64
  ### start-feature
25
- Create a Linear issue + Notion PRD page with bidirectional links.
65
+ Create a Linear issue with a task checklist.
26
66
  ```bash
27
67
  npx pm-skill start-feature "<title>"
28
68
  ```
@@ -45,7 +85,6 @@ Set a relationship between two issues.
45
85
  ```bash
46
86
  npx pm-skill relate <issue1> <issue2> --type <related|similar>
47
87
  ```
48
- Default type: related.
49
88
 
50
89
  ### block
51
90
  Set a blocking dependency (issue1 blocks issue2).
@@ -53,6 +92,30 @@ Set a blocking dependency (issue1 blocks issue2).
53
92
  npx pm-skill block <blocker-issue> <blocked-issue>
54
93
  ```
55
94
 
95
+ ### push-doc
96
+ Upload markdown to Notion. Optionally link to a Linear issue.
97
+ ```bash
98
+ # From file
99
+ npx pm-skill push-doc ./doc.md --title "Title" --parent <page-id> --issue <issue-id>
100
+
101
+ # From content (AI agent use case)
102
+ npx pm-skill push-doc --title "Title" --content "# Markdown..." --issue <issue-id>
103
+ ```
104
+
105
+ ### update-doc
106
+ Replace existing Notion page content with new markdown.
107
+ ```bash
108
+ npx pm-skill update-doc <page-id> ./updated.md
109
+ npx pm-skill update-doc <page-id> --content "# Updated..."
110
+ ```
111
+
112
+ ### create-folder
113
+ Create an empty Notion page as a category/folder.
114
+ ```bash
115
+ npx pm-skill create-folder "Folder Name" --parent <page-id>
116
+ # Returns page ID — use with push-doc --parent
117
+ ```
118
+
56
119
  ### attach-doc
57
120
  Attach a document URL to an issue with type validation.
58
121
  ```bash
@@ -65,21 +128,33 @@ Show issue details including children, relations, and attachments.
65
128
  npx pm-skill get <issue-id>
66
129
  ```
67
130
 
131
+ ### delete
132
+ Delete issue(s) and their linked Notion pages.
133
+ ```bash
134
+ npx pm-skill delete <issue-id>
135
+ npx pm-skill delete <issue-id> --recursive # also delete sub-issues
136
+ ```
137
+
68
138
  ## Configuration
69
139
 
70
140
  All config is per-project (CWD):
71
- - **`.env`** — API keys and IDs
141
+ - **`.env`** — API keys and IDs (never commit this file)
72
142
  - **`config.yml`** — Labels, templates, priorities, severity mappings
73
143
 
74
144
  ## Workflow Patterns
75
145
 
76
146
  ### Feature Development
77
- 1. `npx pm-skill start-feature "Feature Name"` — creates Linear issue + Notion PRD
147
+ 1. `npx pm-skill start-feature "Feature Name"` — creates Linear issue with checklist
78
148
  2. `npx pm-skill add-task ENG-XX "Sub-task 1"` — break down into sub-tasks
79
- 3. `npx pm-skill relate ENG-XX ENG-YY` — link related issues
80
- 4. `npx pm-skill attach-doc ENG-XX --url "..." --title "..." --type source-of-truth`
149
+ 3. `npx pm-skill push-doc ./design.md --issue ENG-XX` — upload docs when ready
150
+ 4. `npx pm-skill relate ENG-XX ENG-YY` link related issues
81
151
 
82
152
  ### Bug Fix
83
153
  1. `npx pm-skill report-bug "Bug Description" --severity high`
84
154
  2. `npx pm-skill add-task ENG-XX "Root cause analysis"`
85
155
  3. `npx pm-skill add-task ENG-XX "Fix and test"`
156
+
157
+ ### Document Management
158
+ 1. `npx pm-skill create-folder "Schema Docs"` — create category
159
+ 2. `npx pm-skill push-doc ./schema.md --parent <folder-id>` — upload under category
160
+ 3. `npx pm-skill update-doc <page-id> ./schema-v2.md` — update later
package/README.md CHANGED
@@ -15,6 +15,12 @@ Structured project management CLI that integrates **Linear** and **Notion**. Des
15
15
  - **attach-doc** — Attach documents with type validation
16
16
  - **get** — View issue details with children, relations, and attachments
17
17
 
18
+ ## Prerequisites
19
+
20
+ - **Node.js 18+** — check with `node --version`
21
+ - **Linear API key** — Linear > Settings > API > Personal API Keys
22
+ - **Notion API key** (optional) — https://www.notion.so/my-integrations
23
+
18
24
  ## Quick Start
19
25
 
20
26
  ```bash
@@ -122,6 +128,10 @@ npx pm-skill start-feature "My Feature"
122
128
  | `doc_types` | Document types for attach-doc validation |
123
129
  | `epics` | Epic definitions (project-specific) |
124
130
 
131
+ ## Security
132
+
133
+ `.env` contains API keys — **never commit it to git**. The `init` command creates `.env` locally, and it's already in `.gitignore`.
134
+
125
135
  ## Per-Project Model
126
136
 
127
137
  All config is per-project. Each project gets its own `.env`, `config.yml`, and instruction files. Run `npx pm-skill init` in each project directory.
package/SKILL.md CHANGED
@@ -3,6 +3,10 @@
3
3
  Linear + Notion integration for structured project management.
4
4
  "Design freedom, usage discipline" — only labels/templates/severity defined in `config.yml` are allowed.
5
5
 
6
+ ## Prerequisites
7
+
8
+ Requires Node.js 18+. If `.env` does not exist in the project, run `init` first.
9
+
6
10
  ## Setup
7
11
 
8
12
  ```bash
package/config.yml CHANGED
@@ -30,32 +30,27 @@ labels:
30
30
 
31
31
  templates:
32
32
  - id: feature
33
- name: Feature PRD
34
- description: "기능 개발용 PRD 템플릿"
35
- notion_template: feature-prd
33
+ name: Feature
34
+ description: "기능 개발 템플릿"
36
35
  linear_labels: [dev]
37
36
  linear_priority: p2
38
37
  - id: bugfix
39
- name: Bug Report
40
- description: "버그 리포트 템플릿"
41
- notion_template: bug-report
38
+ name: Bug Fix
39
+ description: "버그 수정 템플릿"
42
40
  linear_labels: [bug]
43
41
  - id: improvement
44
42
  name: Improvement
45
43
  description: "기존 기능 개선 템플릿"
46
- notion_template: improvement
47
44
  linear_labels: [dev]
48
45
  linear_priority: p2
49
46
  - id: refactor
50
47
  name: Refactor
51
48
  description: "리팩토링 템플릿"
52
- notion_template: refactor
53
49
  linear_labels: [refactor]
54
50
  linear_priority: p3
55
51
  - id: design
56
- name: Design Doc
52
+ name: Design
57
53
  description: "설계 문서 템플릿"
58
- notion_template: design-doc
59
54
  linear_labels: [design, plan]
60
55
  linear_priority: p2
61
56
 
package/dist/config.d.ts CHANGED
@@ -8,7 +8,6 @@ export interface PmTemplate {
8
8
  id: string;
9
9
  name: string;
10
10
  description: string;
11
- notion_template: string;
12
11
  linear_labels: string[];
13
12
  linear_priority?: string;
14
13
  }
package/dist/config.js CHANGED
@@ -26,8 +26,8 @@ function validateTemplates(templates, labelIds) {
26
26
  throw new ConfigValidationError("templates array is empty or missing.");
27
27
  }
28
28
  for (const tmpl of templates) {
29
- if (!tmpl.id || !tmpl.name || !tmpl.description || !tmpl.notion_template) {
30
- throw new ConfigValidationError(`Template '${tmpl.id ?? "(unknown)"}' is missing required fields (id, name, description, notion_template).`);
29
+ if (!tmpl.id || !tmpl.name || !tmpl.description) {
30
+ throw new ConfigValidationError(`Template '${tmpl.id ?? "(unknown)"}' is missing required fields (id, name, description).`);
31
31
  }
32
32
  if (Array.isArray(tmpl.linear_labels)) {
33
33
  for (const lid of tmpl.linear_labels) {
package/dist/env.js CHANGED
@@ -20,7 +20,6 @@ export function resolveFile(filename) {
20
20
  return null;
21
21
  }
22
22
  const REQUIRED_KEYS = ["LINEAR_API_KEY", "LINEAR_DEFAULT_TEAM_ID"];
23
- const NOTION_KEYS = ["NOTION_API_KEY", "NOTION_ROOT_PAGE_ID"];
24
23
  const KEY_HELP = {
25
24
  LINEAR_API_KEY: "Linear > Settings > API > Personal API Keys",
26
25
  LINEAR_DEFAULT_TEAM_ID: "'npx pm-skill init' to discover your team ID",
@@ -66,13 +65,6 @@ export function validateEnv(command) {
66
65
  missing.push(key);
67
66
  }
68
67
  }
69
- const notionCommands = ["start-feature", "report-bug"];
70
- if (notionCommands.includes(command)) {
71
- for (const key of NOTION_KEYS) {
72
- if (!process.env[key])
73
- missing.push(key);
74
- }
75
- }
76
68
  if (missing.length > 0) {
77
69
  const hints = missing
78
70
  .map((k) => ` ${k}: ${KEY_HELP[k] ?? ""}`)
package/dist/notion.d.ts CHANGED
@@ -9,29 +9,20 @@ export declare function validateNotionKey(apiKey: string): Promise<{
9
9
  id: string;
10
10
  name: string;
11
11
  }>;
12
- export declare function buildFeaturePRD(title: string, linearUrl: string): BlockObjectRequest[];
13
- export declare function buildBugReport(title: string, severity: string, linearUrl: string): BlockObjectRequest[];
14
- export declare function buildDesignDoc(title: string, linearUrl: string): BlockObjectRequest[];
15
- export declare function buildImprovement(title: string, linearUrl: string): BlockObjectRequest[];
16
- export declare function buildRefactor(title: string, linearUrl: string): BlockObjectRequest[];
17
- export declare function getTemplateBlocks(notionTemplate: string, title: string, linearUrl: string, severity?: string): BlockObjectRequest[];
18
- export declare function createPage(client: Client, parentPageId: string, title: string, blocks: BlockObjectRequest[]): Promise<{
19
- id: string;
20
- url: string;
21
- }>;
22
- export declare function createDatabaseEntry(client: Client, databaseId: string, properties: Record<string, unknown>): Promise<{
23
- id: string;
24
- url: string;
25
- }>;
26
- export declare function createTemplatedPage(client: Client, parentPageId: string, notionTemplate: string, title: string, linearUrl: string, severity?: string): Promise<{
27
- id: string;
28
- url: string;
29
- }>;
30
12
  export declare function getPage(client: Client, pageId: string): Promise<Record<string, unknown>>;
31
13
  export declare function searchPages(client: Client, query: string): Promise<Array<{
32
14
  id: string;
33
15
  title: string;
34
16
  }>>;
17
+ /**
18
+ * Archive (delete) a Notion page by ID.
19
+ */
20
+ export declare function deletePage(client: Client, pageId: string): Promise<void>;
21
+ /**
22
+ * Extract Notion page ID from a Notion URL.
23
+ * Handles formats like: https://notion.so/abc123def456... or https://www.notion.so/workspace/Page-Title-abc123def456
24
+ */
25
+ export declare function extractNotionPageId(url: string): string | null;
35
26
  /**
36
27
  * Convert markdown to Notion blocks.
37
28
  */
package/dist/notion.js CHANGED
@@ -22,193 +22,7 @@ export async function validateNotionKey(apiKey) {
22
22
  throw new Error("Notion API key validation failed. Check your key at: https://www.notion.so/my-integrations");
23
23
  }
24
24
  }
25
- // ── Block Builders ──
26
- function text(content) {
27
- return [{ type: "text", text: { content } }];
28
- }
29
- function heading1(content) {
30
- return {
31
- object: "block",
32
- type: "heading_1",
33
- heading_1: { rich_text: text(content) },
34
- };
35
- }
36
- function heading2(content) {
37
- return {
38
- object: "block",
39
- type: "heading_2",
40
- heading_2: { rich_text: text(content) },
41
- };
42
- }
43
- function paragraph(content) {
44
- return {
45
- object: "block",
46
- type: "paragraph",
47
- paragraph: { rich_text: text(content) },
48
- };
49
- }
50
- function todo(content, checked = false) {
51
- return {
52
- object: "block",
53
- type: "to_do",
54
- to_do: { rich_text: text(content), checked },
55
- };
56
- }
57
- function numberedItem(content) {
58
- return {
59
- object: "block",
60
- type: "numbered_list_item",
61
- numbered_list_item: { rich_text: text(content) },
62
- };
63
- }
64
- function bookmark(url) {
65
- return {
66
- object: "block",
67
- type: "bookmark",
68
- bookmark: { url },
69
- };
70
- }
71
- function divider() {
72
- return {
73
- object: "block",
74
- type: "divider",
75
- divider: {},
76
- };
77
- }
78
- // ── Template Builders ──
79
- export function buildFeaturePRD(title, linearUrl) {
80
- return [
81
- heading2(`📋 ${title}`),
82
- bookmark(linearUrl),
83
- divider(),
84
- heading1("목표"),
85
- paragraph(""),
86
- heading1("배경"),
87
- paragraph(""),
88
- heading1("요구사항"),
89
- todo("요구사항 1"),
90
- todo("요구사항 2"),
91
- todo("요구사항 3"),
92
- heading1("설계"),
93
- paragraph(""),
94
- heading1("테스트 계획"),
95
- paragraph(""),
96
- ];
97
- }
98
- export function buildBugReport(title, severity, linearUrl) {
99
- return [
100
- heading2(`🐛 ${title} (${severity})`),
101
- bookmark(linearUrl),
102
- divider(),
103
- heading1("재현 단계"),
104
- numberedItem("단계 1"),
105
- numberedItem("단계 2"),
106
- numberedItem("단계 3"),
107
- heading1("예상 동작"),
108
- paragraph(""),
109
- heading1("실제 동작"),
110
- paragraph(""),
111
- heading1("환경"),
112
- paragraph("OS / 브라우저 / 디바이스"),
113
- heading1("해결 방안"),
114
- paragraph(""),
115
- ];
116
- }
117
- export function buildDesignDoc(title, linearUrl) {
118
- return [
119
- heading2(`📐 ${title}`),
120
- bookmark(linearUrl),
121
- divider(),
122
- heading1("개요"),
123
- paragraph(""),
124
- heading1("제약 조건"),
125
- paragraph(""),
126
- heading1("옵션 비교"),
127
- paragraph(""),
128
- heading1("결정"),
129
- paragraph(""),
130
- heading1("후속 작업"),
131
- todo("후속 작업 1"),
132
- todo("후속 작업 2"),
133
- ];
134
- }
135
- export function buildImprovement(title, linearUrl) {
136
- return [
137
- heading2(`✨ ${title}`),
138
- bookmark(linearUrl),
139
- divider(),
140
- heading1("현재 상태"),
141
- paragraph(""),
142
- heading1("개선 목표"),
143
- paragraph(""),
144
- heading1("변경 사항"),
145
- todo("변경 1"),
146
- todo("변경 2"),
147
- todo("변경 3"),
148
- heading1("영향 범위"),
149
- paragraph(""),
150
- ];
151
- }
152
- export function buildRefactor(title, linearUrl) {
153
- return [
154
- heading2(`🔧 ${title}`),
155
- bookmark(linearUrl),
156
- divider(),
157
- heading1("리팩토링 대상"),
158
- paragraph(""),
159
- heading1("현재 문제점"),
160
- paragraph(""),
161
- heading1("변경 계획"),
162
- todo("단계 1"),
163
- todo("단계 2"),
164
- heading1("검증 방법"),
165
- paragraph(""),
166
- ];
167
- }
168
- // ── Template Dispatcher ──
169
- const TEMPLATE_BUILDERS = {
170
- "feature-prd": buildFeaturePRD,
171
- "bug-report": (t, u, s) => buildBugReport(t, s ?? "medium", u),
172
- "design-doc": buildDesignDoc,
173
- improvement: buildImprovement,
174
- refactor: buildRefactor,
175
- };
176
- export function getTemplateBlocks(notionTemplate, title, linearUrl, severity) {
177
- const builder = TEMPLATE_BUILDERS[notionTemplate];
178
- if (!builder) {
179
- const available = Object.keys(TEMPLATE_BUILDERS).join(", ");
180
- throw new Error(`notion_template '${notionTemplate}'에 대한 빌더가 없습니다.\n등록된 템플릿: ${available}`);
181
- }
182
- return builder(title, linearUrl, severity);
183
- }
184
25
  // ── Page CRUD ──
185
- export async function createPage(client, parentPageId, title, blocks) {
186
- const response = await client.pages.create({
187
- parent: { page_id: parentPageId },
188
- properties: {
189
- title: { title: [{ text: { content: title } }] },
190
- },
191
- children: blocks,
192
- });
193
- return {
194
- id: response.id,
195
- url: `https://notion.so/${response.id.replace(/-/g, "")}`,
196
- };
197
- }
198
- export async function createDatabaseEntry(client, databaseId, properties) {
199
- const response = await client.pages.create({
200
- parent: { database_id: databaseId },
201
- properties: properties,
202
- });
203
- return {
204
- id: response.id,
205
- url: `https://notion.so/${response.id.replace(/-/g, "")}`,
206
- };
207
- }
208
- export async function createTemplatedPage(client, parentPageId, notionTemplate, title, linearUrl, severity) {
209
- const blocks = getTemplateBlocks(notionTemplate, title, linearUrl, severity);
210
- return createPage(client, parentPageId, title, blocks);
211
- }
212
26
  export async function getPage(client, pageId) {
213
27
  return (await client.pages.retrieve({ page_id: pageId }));
214
28
  }
@@ -224,6 +38,26 @@ export async function searchPages(client, query) {
224
38
  "(untitled)",
225
39
  }));
226
40
  }
41
+ // ── Page Deletion ──
42
+ /**
43
+ * Archive (delete) a Notion page by ID.
44
+ */
45
+ export async function deletePage(client, pageId) {
46
+ await client.pages.update({ page_id: pageId, archived: true });
47
+ }
48
+ /**
49
+ * Extract Notion page ID from a Notion URL.
50
+ * Handles formats like: https://notion.so/abc123def456... or https://www.notion.so/workspace/Page-Title-abc123def456
51
+ */
52
+ export function extractNotionPageId(url) {
53
+ const match = url.match(/([a-f0-9]{32})(?:\?|$)/);
54
+ if (match) {
55
+ const raw = match[1];
56
+ // Format as UUID
57
+ return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
58
+ }
59
+ return null;
60
+ }
227
61
  // ── Markdown Upload ──
228
62
  /**
229
63
  * Convert markdown to Notion blocks.
package/dist/workflows.js CHANGED
@@ -5,7 +5,7 @@ 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
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, } from "./notion.js";
8
+ import { getNotionClient, validateNotionKey, searchPages, createPageFromMarkdown, updatePageContent, deletePage, extractNotionPageId, } from "./notion.js";
9
9
  // ── Init ──
10
10
  function copyBundledFile(srcName, destPath) {
11
11
  if (existsSync(destPath)) {
@@ -243,23 +243,22 @@ async function startFeature(ctx, args) {
243
243
  const priority = tmpl.linear_priority
244
244
  ? resolvePriority(ctx.config, tmpl.linear_priority)
245
245
  : undefined;
246
+ const description = [
247
+ `## Tasks`,
248
+ `- [ ] Implementation`,
249
+ `- [ ] Write/update documentation (\`push-doc\`)`,
250
+ `- [ ] Tests`,
251
+ `- [ ] Review`,
252
+ ].join("\n");
246
253
  const issue = await createIssue(ctx.linear, {
247
254
  teamId: ctx.env.LINEAR_DEFAULT_TEAM_ID,
248
255
  title,
256
+ description,
249
257
  priority,
250
258
  labelIds,
251
259
  projectId: ctx.env.LINEAR_DEFAULT_PROJECT_ID,
252
260
  });
253
- console.log(`[Linear] Issue created: ${issue.identifier} — ${issue.url}`);
254
- if (!ctx.notion || !ctx.env.NOTION_ROOT_PAGE_ID) {
255
- console.log("[Notion] Not configured — skipping page creation");
256
- return;
257
- }
258
- const page = await createTemplatedPage(ctx.notion, ctx.env.NOTION_ROOT_PAGE_ID, tmpl.notion_template, title, issue.url);
259
- console.log(`[Notion] Page created: ${page.url}`);
260
- await createAttachment(ctx.linear, issue.id, page.url, `${title} — PRD`);
261
- console.log(`[Link] Linear ↔ Notion linked`);
262
- console.log(`\n✅ Feature started: ${issue.identifier} | Notion: ${page.url}`);
261
+ console.log(`✅ Feature started: ${issue.identifier} — ${issue.url}`);
263
262
  }
264
263
  async function reportBug(ctx, args) {
265
264
  const title = args._[0];
@@ -272,38 +271,25 @@ async function reportBug(ctx, args) {
272
271
  const teamLabels = await getTeamLabels(ctx.linear, ctx.env.LINEAR_DEFAULT_TEAM_ID);
273
272
  const configLabels = tmpl.linear_labels.map((lid) => validateLabel(ctx.config, lid).name);
274
273
  const labelIds = resolveLabels(configLabels, teamLabels);
274
+ const description = [
275
+ `**Severity: ${severity}**`,
276
+ ``,
277
+ `## Tasks`,
278
+ `- [ ] Reproduce`,
279
+ `- [ ] Root cause analysis`,
280
+ `- [ ] Fix & write tests`,
281
+ `- [ ] Write/update documentation (\`push-doc\`)`,
282
+ `- [ ] Review`,
283
+ ].join("\n");
275
284
  const issue = await createIssue(ctx.linear, {
276
285
  teamId: ctx.env.LINEAR_DEFAULT_TEAM_ID,
277
286
  title,
287
+ description,
278
288
  priority,
279
289
  labelIds,
280
290
  projectId: ctx.env.LINEAR_DEFAULT_PROJECT_ID,
281
291
  });
282
- console.log(`[Linear] Bug created: ${issue.identifier} (severity: ${severity}) — ${issue.url}`);
283
- if (!ctx.notion) {
284
- console.log("[Notion] Not configured — skipping");
285
- return;
286
- }
287
- let notionUrl;
288
- if (ctx.env.NOTION_BUG_DB_ID) {
289
- const entry = await createDatabaseEntry(ctx.notion, ctx.env.NOTION_BUG_DB_ID, {
290
- Name: { title: [{ text: { content: title } }] },
291
- });
292
- notionUrl = entry.url;
293
- console.log(`[Notion] Bug DB entry: ${notionUrl}`);
294
- }
295
- else if (ctx.env.NOTION_ROOT_PAGE_ID) {
296
- const page = await createTemplatedPage(ctx.notion, ctx.env.NOTION_ROOT_PAGE_ID, tmpl.notion_template, title, issue.url, severity);
297
- notionUrl = page.url;
298
- console.log(`[Notion] Bug report page: ${notionUrl}`);
299
- }
300
- else {
301
- console.log("[Notion] NOTION_ROOT_PAGE_ID not set — skipping");
302
- return;
303
- }
304
- await createAttachment(ctx.linear, issue.id, notionUrl, `${title} — Bug Report`);
305
- console.log(`[Link] Linear ↔ Notion linked`);
306
- console.log(`\n✅ Bug reported: ${issue.identifier} | Notion: ${notionUrl}`);
292
+ console.log(`✅ Bug reported: ${issue.identifier} (severity: ${severity}) — ${issue.url}`);
307
293
  }
308
294
  async function addTask(ctx, args) {
309
295
  const parentIdentifier = args._[0];
@@ -394,39 +380,49 @@ async function get(ctx, args) {
394
380
  }
395
381
  }
396
382
  async function pushDoc(ctx, args) {
397
- const identifier = args._[0];
398
- const filePath = args._[1];
383
+ const filePath = args._[0];
399
384
  const content = args.content;
400
385
  const title = args.title;
401
- if (!identifier || (!filePath && !content)) {
402
- throw new Error("Usage: npx pm-skill push-doc <issue> <file.md> [--title T]\n" +
403
- " npx pm-skill push-doc <issue> --title T --content \"# Markdown...\"");
386
+ const parentPageId = args.parent;
387
+ const issueId = args.issue;
388
+ if (!filePath && !content) {
389
+ throw new Error("Usage: npx pm-skill push-doc <file.md> [--title T] [--parent P] [--issue I]\n" +
390
+ " npx pm-skill push-doc --title T --content \"# md\" [--parent P] [--issue I]");
404
391
  }
405
- if (!ctx.notion || !ctx.env.NOTION_ROOT_PAGE_ID) {
392
+ if (!ctx.notion) {
406
393
  throw new Error("Notion is not configured. Run 'npx pm-skill init' with --notion-key.");
407
394
  }
395
+ const targetParent = parentPageId ?? ctx.env.NOTION_ROOT_PAGE_ID;
396
+ if (!targetParent) {
397
+ throw new Error("No Notion parent page. Set NOTION_ROOT_PAGE_ID or use --parent <page-id>.");
398
+ }
408
399
  // Read markdown
409
400
  let markdown;
410
- if (filePath) {
411
- if (!existsSync(filePath)) {
412
- throw new Error(`File not found: ${filePath}`);
413
- }
401
+ if (filePath && !existsSync(filePath) && !content) {
402
+ throw new Error(`File not found: ${filePath}`);
403
+ }
404
+ if (filePath && existsSync(filePath)) {
414
405
  markdown = readFileSync(filePath, "utf-8");
415
406
  }
416
- else {
407
+ else if (content) {
417
408
  markdown = content;
418
409
  }
410
+ else {
411
+ throw new Error("Provide a file path or --content.");
412
+ }
419
413
  // Determine title
420
414
  const docTitle = title ?? (filePath ? filePath.replace(/^.*[\\/]/, "").replace(/\.md$/, "") : "Untitled");
421
- // Get Linear issue for linking
422
- const issue = await getIssue(ctx.linear, identifier);
423
415
  // Create Notion page
424
- const page = await createPageFromMarkdown(ctx.notion, ctx.env.NOTION_ROOT_PAGE_ID, docTitle, markdown);
416
+ const page = await createPageFromMarkdown(ctx.notion, targetParent, docTitle, markdown);
425
417
  console.log(`[Notion] Page created: "${docTitle}" — ${page.url}`);
426
- // Link to Linear issue
427
- await createAttachment(ctx.linear, issue.id, page.url, docTitle, "source-of-truth");
428
- console.log(`[Link] Attached to ${issue.identifier}`);
429
- console.log(`\n✅ Document pushed: ${issue.identifier} | ${page.url}`);
418
+ console.log(`[Notion] Page ID: ${page.id}`);
419
+ // Optionally link to Linear issue
420
+ if (issueId) {
421
+ const issue = await getIssue(ctx.linear, issueId);
422
+ await createAttachment(ctx.linear, issue.id, page.url, docTitle, "source-of-truth");
423
+ console.log(`[Link] Attached to ${issue.identifier}`);
424
+ }
425
+ console.log(`\n✅ Document pushed: ${page.url}`);
430
426
  }
431
427
  async function updateDoc(ctx, args) {
432
428
  const pageId = args._[0];
@@ -452,15 +448,169 @@ async function updateDoc(ctx, args) {
452
448
  await updatePageContent(ctx.notion, pageId, markdown);
453
449
  console.log(`✅ Page updated: ${pageId}`);
454
450
  }
451
+ async function createFolder(ctx, args) {
452
+ const folderName = args._[0];
453
+ const parentPageId = args.parent;
454
+ if (!folderName) {
455
+ throw new Error("Usage: npx pm-skill create-folder <name> [--parent <page-id>]");
456
+ }
457
+ if (!ctx.notion) {
458
+ throw new Error("Notion is not configured. Run 'npx pm-skill init' with --notion-key.");
459
+ }
460
+ const targetParent = parentPageId ?? ctx.env.NOTION_ROOT_PAGE_ID;
461
+ if (!targetParent) {
462
+ throw new Error("No Notion parent page. Set NOTION_ROOT_PAGE_ID or use --parent <page-id>.");
463
+ }
464
+ const response = await ctx.notion.pages.create({
465
+ parent: { page_id: targetParent },
466
+ properties: {
467
+ title: { title: [{ text: { content: folderName } }] },
468
+ },
469
+ children: [],
470
+ });
471
+ const pageId = response.id;
472
+ const url = `https://notion.so/${pageId.replace(/-/g, "")}`;
473
+ console.log(`✅ Folder created: "${folderName}"`);
474
+ console.log(` Page ID: ${pageId}`);
475
+ console.log(` URL: ${url}`);
476
+ console.log(`\nUse with: npx pm-skill push-doc <issue> <file> --parent ${pageId}`);
477
+ }
455
478
  async function del(ctx, args) {
456
479
  const identifiers = args._;
480
+ const recursive = !!args.recursive;
457
481
  if (identifiers.length === 0) {
458
- throw new Error("Usage: npx pm-skill delete <issue> [issue2 ...]");
482
+ throw new Error("Usage: npx pm-skill delete <issue> [issue2 ...] [--recursive]");
459
483
  }
460
484
  for (const identifier of identifiers) {
461
- const issue = await getIssue(ctx.linear, identifier);
462
- await deleteIssue(ctx.linear, issue.id);
463
- console.log(`✅ Deleted: ${issue.identifier} (${issue.title})`);
485
+ const detail = await getIssueDetail(ctx.linear, identifier);
486
+ // Check for children
487
+ if (detail.children.length > 0 && !recursive) {
488
+ console.log(`⚠️ ${detail.issue.identifier} has ${detail.children.length} sub-issue(s):`);
489
+ for (const child of detail.children) {
490
+ console.log(` ${child.identifier}: ${child.title}`);
491
+ }
492
+ throw new Error(`Use --recursive to delete ${detail.issue.identifier} and its sub-issues.`);
493
+ }
494
+ // Recursively delete children first
495
+ if (detail.children.length > 0 && recursive) {
496
+ for (const child of detail.children) {
497
+ const childDetail = await getIssueDetail(ctx.linear, child.identifier);
498
+ // Delete child's Notion pages
499
+ if (ctx.notion) {
500
+ for (const att of childDetail.attachments) {
501
+ if (att.url.includes("notion.so")) {
502
+ const pageId = extractNotionPageId(att.url);
503
+ if (pageId) {
504
+ try {
505
+ await deletePage(ctx.notion, pageId);
506
+ console.log(` [Notion] Deleted: ${att.title}`);
507
+ }
508
+ catch { /* skip */ }
509
+ }
510
+ }
511
+ }
512
+ }
513
+ await deleteIssue(ctx.linear, child.id);
514
+ console.log(` Deleted sub-issue: ${child.identifier}`);
515
+ }
516
+ }
517
+ // Delete linked Notion pages
518
+ if (ctx.notion && detail.attachments.length > 0) {
519
+ for (const att of detail.attachments) {
520
+ if (att.url.includes("notion.so")) {
521
+ const pageId = extractNotionPageId(att.url);
522
+ if (pageId) {
523
+ try {
524
+ await deletePage(ctx.notion, pageId);
525
+ console.log(` [Notion] Deleted: ${att.title}`);
526
+ }
527
+ catch {
528
+ console.log(` [Notion] Could not delete: ${att.url}`);
529
+ }
530
+ }
531
+ }
532
+ }
533
+ }
534
+ // Delete Linear issue
535
+ await deleteIssue(ctx.linear, detail.issue.id);
536
+ console.log(`✅ Deleted: ${detail.issue.identifier} (${detail.issue.title})`);
537
+ }
538
+ }
539
+ async function selectProject(args) {
540
+ // Load env manually — we need API key and team ID but not project ID
541
+ const { loadEnvFile, writeEnvFile } = await import("./env.js");
542
+ loadEnvFile();
543
+ const apiKey = process.env.LINEAR_API_KEY;
544
+ const teamId = process.env.LINEAR_DEFAULT_TEAM_ID;
545
+ if (!apiKey || !teamId) {
546
+ throw new Error("LINEAR_API_KEY and LINEAR_DEFAULT_TEAM_ID must be set. Run 'npx pm-skill init' first.");
547
+ }
548
+ const client = getLinearClient(apiKey);
549
+ const projects = await getTeamProjects(client, teamId);
550
+ if (projects.length === 0) {
551
+ console.log("No projects found for this team.");
552
+ return;
553
+ }
554
+ const projectIdArg = args._[0];
555
+ if (projectIdArg) {
556
+ // Direct selection by ID or name
557
+ const match = projects.find((p) => p.id === projectIdArg || p.name.toLowerCase() === projectIdArg.toLowerCase());
558
+ if (!match) {
559
+ console.log("Available projects:");
560
+ for (const p of projects) {
561
+ console.log(` ${p.name} | ${p.id}`);
562
+ }
563
+ throw new Error(`Project '${projectIdArg}' not found.`);
564
+ }
565
+ writeEnvFile(process.cwd(), { LINEAR_DEFAULT_PROJECT_ID: match.id });
566
+ console.log(`✅ Selected project: "${match.name}" (${match.id})`);
567
+ }
568
+ else {
569
+ // List projects with current marker
570
+ const currentId = process.env.LINEAR_DEFAULT_PROJECT_ID;
571
+ console.log("Available projects:");
572
+ for (const p of projects) {
573
+ const marker = p.id === currentId ? " ← current" : "";
574
+ console.log(` ${p.name} | ${p.id}${marker}`);
575
+ }
576
+ console.log(`\nUsage: npx pm-skill select-project "<name or id>"`);
577
+ }
578
+ }
579
+ async function selectPage(args) {
580
+ const { loadEnvFile, writeEnvFile } = await import("./env.js");
581
+ loadEnvFile();
582
+ const apiKey = process.env.NOTION_API_KEY;
583
+ if (!apiKey) {
584
+ throw new Error("NOTION_API_KEY must be set. Run 'npx pm-skill init' with --notion-key first.");
585
+ }
586
+ const client = getNotionClient(apiKey);
587
+ const pages = await searchPages(client, "");
588
+ if (pages.length === 0) {
589
+ console.log("No pages shared with this integration.");
590
+ console.log("Share a page in Notion: page menu → Connections → add your integration");
591
+ return;
592
+ }
593
+ const pageArg = args._[0];
594
+ if (pageArg) {
595
+ const match = pages.find((p) => p.id === pageArg || p.title.toLowerCase() === pageArg.toLowerCase());
596
+ if (!match) {
597
+ console.log("Available pages:");
598
+ for (const p of pages) {
599
+ console.log(` ${p.title} | ${p.id}`);
600
+ }
601
+ throw new Error(`Page '${pageArg}' not found.`);
602
+ }
603
+ writeEnvFile(process.cwd(), { NOTION_ROOT_PAGE_ID: match.id });
604
+ console.log(`✅ Selected page: "${match.title}" (${match.id})`);
605
+ }
606
+ else {
607
+ const currentId = process.env.NOTION_ROOT_PAGE_ID;
608
+ console.log("Available pages:");
609
+ for (const p of pages) {
610
+ const marker = p.id === currentId ? " ← current" : "";
611
+ console.log(` ${p.title} | ${p.id}${marker}`);
612
+ }
613
+ console.log(`\nUsage: npx pm-skill select-page "<name or id>"`);
464
614
  }
465
615
  }
466
616
  // ── Command Registry ──
@@ -474,14 +624,15 @@ const COMMANDS = {
474
624
  "attach-doc": attachDoc,
475
625
  "push-doc": pushDoc,
476
626
  "update-doc": updateDoc,
627
+ "create-folder": createFolder,
477
628
  delete: del,
478
629
  get,
479
630
  };
480
631
  // ── Main ──
481
632
  async function main() {
482
633
  const args = minimist(process.argv.slice(2), {
483
- string: ["severity", "type", "url", "title", "content", "linear-key", "notion-key", "team-id", "project-id", "notion-page"],
484
- boolean: ["sync", "version"],
634
+ string: ["severity", "type", "url", "title", "content", "parent", "issue", "linear-key", "notion-key", "team-id", "project-id", "notion-page"],
635
+ boolean: ["sync", "version", "recursive"],
485
636
  alias: { s: "severity", t: "type" },
486
637
  });
487
638
  // --version
@@ -501,7 +652,9 @@ Commands:
501
652
  init --linear-key K [--notion-key K]
502
653
  Initialize project (validates keys, creates .env, config.yml, SKILL.md, AGENTS.md)
503
654
  setup [--sync] Verify config & label matching (--sync creates missing labels)
504
- start-feature <title> Start feature (Linear issue + Notion PRD)
655
+ select-project [name-or-id] List or switch Linear project
656
+ select-page [name-or-id] List or switch Notion root page
657
+ start-feature <title> Start feature (Linear issue with task checklist)
505
658
  report-bug <title> [--severity S] File bug report (severity: urgent/high/medium/low)
506
659
  add-task <parent> <title> Add sub-task to an issue
507
660
  relate <issue1> <issue2> [--type T] Link issues (type: related/similar)
@@ -509,25 +662,35 @@ Commands:
509
662
  attach-doc <issue> --url U --title T --type Y
510
663
  Attach document (type: source-of-truth/issue-tracking/domain-knowledge)
511
664
  get <issue> Show issue details
512
- push-doc <issue> <file.md> [--title T]
513
- Upload markdown to Notion + link to issue
514
- push-doc <issue> --title T --content "# md"
665
+ push-doc <file.md> [--title T] [--parent P] [--issue I]
666
+ Upload markdown to Notion (optionally link to issue)
667
+ push-doc --title T --content "# md" [--parent P] [--issue I]
515
668
  Push content directly (for AI agents)
516
669
  update-doc <page-id> <file.md> Replace Notion page content with markdown
517
670
  update-doc <page-id> --content "# md"
518
671
  Replace content directly
519
- delete <issue> [issue2 ...] Delete issue(s)
672
+ create-folder <name> [--parent P] Create Notion folder (returns page ID for --parent)
673
+ delete <issue> [issue2 ...] [--recursive]
674
+ Delete issue(s) + linked Notion pages (--recursive for sub-issues)
520
675
  help Show this help
521
676
  --version Show version
522
677
 
523
678
  All config is per-project (CWD). Run 'npx pm-skill init' in each project.`);
524
679
  return;
525
680
  }
526
- // init runs independently — no env/config validation
681
+ // These commands run independently — no full env/config validation
527
682
  if (command === "init") {
528
683
  await init(args);
529
684
  return;
530
685
  }
686
+ if (command === "select-project") {
687
+ await selectProject(args);
688
+ return;
689
+ }
690
+ if (command === "select-page") {
691
+ await selectPage(args);
692
+ return;
693
+ }
531
694
  const cmdFn = COMMANDS[command];
532
695
  if (!cmdFn) {
533
696
  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.3",
3
+ "version": "1.1.5",
4
4
  "description": "Structured project management CLI — Linear + Notion integration for AI coding assistants (Claude Code, Codex)",
5
5
  "type": "module",
6
6
  "bin": {