pm-skill 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+ import minimist from "minimist";
3
+ import { validateEnv, writeEnvFile, GLOBAL_DIR } from "./env.js";
4
+ import { loadConfig, getTemplate, resolvePriority, resolveSeverity, validateDocType, validateLabel, } from "./config.js";
5
+ import { getLinearClient, validateLinearKey, createIssue, getIssue, getIssueDetail, createRelation, createAttachment, getTeams, getTeamStates, getTeamLabels, resolveLabels, } from "./linear.js";
6
+ import { getNotionClient, createTemplatedPage, createDatabaseEntry, validateNotionKey, } from "./notion.js";
7
+ // ── Init (runs before context — no env/config validation needed) ──
8
+ import { existsSync, copyFileSync, mkdirSync } from "fs";
9
+ import { resolve, dirname } from "path";
10
+ import { fileURLToPath } from "url";
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const PKG_ROOT = resolve(__dirname, "..");
13
+ function copyDefaultConfig(targetDir) {
14
+ const targetConfig = resolve(targetDir, "config.yml");
15
+ if (existsSync(targetConfig)) {
16
+ console.log(` config.yml already exists at ${targetConfig} — skipped`);
17
+ return;
18
+ }
19
+ const src = resolve(PKG_ROOT, "config.yml");
20
+ if (!existsSync(src)) {
21
+ console.log(" Warning: bundled config.yml not found — skipping copy");
22
+ return;
23
+ }
24
+ mkdirSync(targetDir, { recursive: true });
25
+ copyFileSync(src, targetConfig);
26
+ console.log(` config.yml copied to ${targetConfig}`);
27
+ }
28
+ async function init(args) {
29
+ const linearKey = args["linear-key"];
30
+ const notionKey = args["notion-key"];
31
+ const isGlobal = !!args.global;
32
+ const teamId = args["team-id"];
33
+ const projectId = args["project-id"];
34
+ const notionPage = args["notion-page"];
35
+ if (!linearKey) {
36
+ throw new Error("Usage: pm-skill init --linear-key <key> [--notion-key <key>] [--global]\n" +
37
+ " --linear-key Linear API key (required)\n" +
38
+ " --notion-key Notion API key (optional)\n" +
39
+ " --global Save to ~/.pm-skill/ instead of CWD\n" +
40
+ " --team-id Linear team ID (auto-detected if omitted)\n" +
41
+ " --project-id Linear project ID (optional)\n" +
42
+ " --notion-page Notion root page ID (optional)");
43
+ }
44
+ const targetDir = isGlobal ? GLOBAL_DIR : process.cwd();
45
+ const targetLabel = isGlobal ? `~/.pm-skill/` : "CWD";
46
+ console.log(`=== pm-skill init (${targetLabel}) ===\n`);
47
+ // 1. Validate Linear key
48
+ console.log("[Linear] Validating API key...");
49
+ const linearUser = await validateLinearKey(linearKey);
50
+ console.log(` Authenticated as: ${linearUser.name} (${linearUser.email})`);
51
+ // 2. Discover teams + auto-select
52
+ const client = getLinearClient(linearKey);
53
+ const teams = await getTeams(client);
54
+ let selectedTeamId = teamId;
55
+ if (!selectedTeamId) {
56
+ if (teams.length === 1) {
57
+ selectedTeamId = teams[0].id;
58
+ console.log(` Auto-selected team: ${teams[0].key} (${teams[0].name})`);
59
+ }
60
+ else {
61
+ console.log("\n Available teams:");
62
+ for (const team of teams) {
63
+ console.log(` ${team.key} | ${team.name} | ${team.id}`);
64
+ }
65
+ selectedTeamId = teams[0].id;
66
+ console.log(` Using first team: ${teams[0].key}. Override with --team-id <id>`);
67
+ }
68
+ }
69
+ else {
70
+ const match = teams.find((t) => t.id === teamId || t.key === teamId);
71
+ if (match) {
72
+ selectedTeamId = match.id;
73
+ console.log(` Team: ${match.key} (${match.name})`);
74
+ }
75
+ else {
76
+ throw new Error(`Team '${teamId}' not found. Run 'pm-skill init --linear-key <key>' to see available teams.`);
77
+ }
78
+ }
79
+ // 3. Validate Notion key (optional)
80
+ if (notionKey) {
81
+ console.log("\n[Notion] Validating API key...");
82
+ const notionUser = await validateNotionKey(notionKey);
83
+ console.log(` Authenticated as: ${notionUser.name}`);
84
+ }
85
+ // 4. Write .env
86
+ console.log(`\n[Config] Writing .env to ${targetLabel}...`);
87
+ const envEntries = {
88
+ LINEAR_API_KEY: linearKey,
89
+ LINEAR_DEFAULT_TEAM_ID: selectedTeamId,
90
+ };
91
+ if (projectId)
92
+ envEntries.LINEAR_DEFAULT_PROJECT_ID = projectId;
93
+ if (notionKey)
94
+ envEntries.NOTION_API_KEY = notionKey;
95
+ if (notionPage)
96
+ envEntries.NOTION_ROOT_PAGE_ID = notionPage;
97
+ const envPath = writeEnvFile(targetDir, envEntries);
98
+ console.log(` Written: ${envPath}`);
99
+ // 5. Copy config.yml if missing
100
+ console.log(`\n[Config] Checking config.yml...`);
101
+ copyDefaultConfig(targetDir);
102
+ // 6. Summary
103
+ console.log(`\n=== Init complete ===`);
104
+ console.log(` .env: ${envPath}`);
105
+ console.log(` config.yml: ${resolve(targetDir, "config.yml")}`);
106
+ console.log(` Linear user: ${linearUser.name}`);
107
+ console.log(` Team: ${selectedTeamId}`);
108
+ if (notionKey)
109
+ console.log(` Notion: connected`);
110
+ console.log(`\nNext steps:`);
111
+ if (isGlobal) {
112
+ console.log(` - Per-project overrides: create .env in your project directory`);
113
+ }
114
+ console.log(` - Customize config.yml labels/templates for your project`);
115
+ console.log(` - Run 'pm-skill setup' to verify label matching`);
116
+ }
117
+ // ── Commands ──
118
+ async function setup(ctx) {
119
+ console.log("=== PM Skill Setup ===\n");
120
+ // 1. Teams
121
+ const teams = await getTeams(ctx.linear);
122
+ console.log("📋 Linear 팀 목록:");
123
+ for (const team of teams) {
124
+ const marker = team.id === ctx.env.LINEAR_DEFAULT_TEAM_ID ? " ← 현재 설정" : "";
125
+ console.log(` ${team.key} | ${team.name} | ${team.id}${marker}`);
126
+ }
127
+ // 2. States
128
+ const teamId = ctx.env.LINEAR_DEFAULT_TEAM_ID;
129
+ console.log(`\n📊 팀 상태 목록 (${teamId}):`);
130
+ const states = await getTeamStates(ctx.linear, teamId);
131
+ for (const state of states) {
132
+ console.log(` ${state.name} (${state.type}) | ${state.id}`);
133
+ }
134
+ // 3. Labels + matching
135
+ console.log("\n🏷️ Linear 라벨 목록:");
136
+ const teamLabels = await getTeamLabels(ctx.linear, teamId);
137
+ for (const label of teamLabels) {
138
+ console.log(` ${label.name} | ${label.id}`);
139
+ }
140
+ // 4. Config label matching
141
+ console.log("\n🔗 Config ↔ Linear 라벨 매칭:");
142
+ const linearLabelMap = new Map(teamLabels.map((l) => [l.name.toLowerCase(), l]));
143
+ for (const configLabel of ctx.config.labels) {
144
+ const match = linearLabelMap.get(configLabel.name.toLowerCase());
145
+ if (match) {
146
+ console.log(` ✅ ${configLabel.id} (${configLabel.name}) → ${match.id}`);
147
+ }
148
+ else {
149
+ console.log(` ⚠️ ${configLabel.id} (${configLabel.name}) → 매칭 없음! Linear에 '${configLabel.name}' 라벨을 생성하세요.`);
150
+ }
151
+ }
152
+ // 5. .env guide
153
+ console.log("\n📝 .env 설정 안내:");
154
+ console.log(" LINEAR_API_KEY=<위에서 사용 중인 키>");
155
+ console.log(` LINEAR_DEFAULT_TEAM_ID=${teamId}`);
156
+ if (ctx.env.LINEAR_DEFAULT_PROJECT_ID) {
157
+ console.log(` LINEAR_DEFAULT_PROJECT_ID=${ctx.env.LINEAR_DEFAULT_PROJECT_ID}`);
158
+ }
159
+ else {
160
+ console.log(" LINEAR_DEFAULT_PROJECT_ID=<Linear 프로젝트 ID (선택)>");
161
+ }
162
+ console.log("\nNotion 설정은 https://www.notion.so/my-integrations 에서 키를 발급하세요.");
163
+ }
164
+ async function startFeature(ctx, args) {
165
+ const title = args._[0];
166
+ if (!title) {
167
+ throw new Error("사용법: start-feature <제목>");
168
+ }
169
+ const tmpl = getTemplate(ctx.config, "feature");
170
+ const teamLabels = await getTeamLabels(ctx.linear, ctx.env.LINEAR_DEFAULT_TEAM_ID);
171
+ const configLabels = tmpl.linear_labels.map((lid) => validateLabel(ctx.config, lid).name);
172
+ const labelIds = resolveLabels(configLabels, teamLabels);
173
+ const priority = tmpl.linear_priority
174
+ ? resolvePriority(ctx.config, tmpl.linear_priority)
175
+ : undefined;
176
+ // 1. Linear issue
177
+ const issue = await createIssue(ctx.linear, {
178
+ teamId: ctx.env.LINEAR_DEFAULT_TEAM_ID,
179
+ title,
180
+ priority,
181
+ labelIds,
182
+ projectId: ctx.env.LINEAR_DEFAULT_PROJECT_ID,
183
+ });
184
+ const issueId = issue.identifier;
185
+ const issueUrl = issue.url;
186
+ console.log(`[Linear] 이슈 생성: ${issueId} — ${issueUrl}`);
187
+ // 2. Notion page
188
+ if (!ctx.notion || !ctx.env.NOTION_ROOT_PAGE_ID) {
189
+ console.log("[Notion] Notion 설정 없음 — 페이지 생성 생략");
190
+ return;
191
+ }
192
+ const page = await createTemplatedPage(ctx.notion, ctx.env.NOTION_ROOT_PAGE_ID, tmpl.notion_template, title, issueUrl);
193
+ console.log(`[Notion] 페이지 생성: ${page.url}`);
194
+ // 3. Link Linear → Notion
195
+ await createAttachment(ctx.linear, issue.id, page.url, `${title} — PRD`);
196
+ console.log(`[Link] Linear ↔ Notion 연결 완료`);
197
+ console.log(`\n✅ 기능 시작: ${issueId} | Notion: ${page.url}`);
198
+ }
199
+ async function reportBug(ctx, args) {
200
+ const title = args._[0];
201
+ if (!title) {
202
+ throw new Error("사용법: report-bug <제목> [--severity high]");
203
+ }
204
+ const severity = args.severity ?? "medium";
205
+ const priority = resolveSeverity(ctx.config, severity);
206
+ const tmpl = getTemplate(ctx.config, "bugfix");
207
+ const teamLabels = await getTeamLabels(ctx.linear, ctx.env.LINEAR_DEFAULT_TEAM_ID);
208
+ const configLabels = tmpl.linear_labels.map((lid) => validateLabel(ctx.config, lid).name);
209
+ const labelIds = resolveLabels(configLabels, teamLabels);
210
+ // 1. Linear issue
211
+ const issue = await createIssue(ctx.linear, {
212
+ teamId: ctx.env.LINEAR_DEFAULT_TEAM_ID,
213
+ title,
214
+ priority,
215
+ labelIds,
216
+ projectId: ctx.env.LINEAR_DEFAULT_PROJECT_ID,
217
+ });
218
+ const issueId = issue.identifier;
219
+ const issueUrl = issue.url;
220
+ console.log(`[Linear] 버그 이슈 생성: ${issueId} (severity: ${severity}) — ${issueUrl}`);
221
+ // 2. Notion
222
+ if (!ctx.notion) {
223
+ console.log("[Notion] Notion 설정 없음 — 문서 생성 생략");
224
+ return;
225
+ }
226
+ let notionUrl;
227
+ if (ctx.env.NOTION_BUG_DB_ID) {
228
+ // DB 엔트리
229
+ const entry = await createDatabaseEntry(ctx.notion, ctx.env.NOTION_BUG_DB_ID, {
230
+ Name: { title: [{ text: { content: title } }] },
231
+ });
232
+ notionUrl = entry.url;
233
+ console.log(`[Notion] 버그 DB 엔트리 생성: ${notionUrl}`);
234
+ }
235
+ else if (ctx.env.NOTION_ROOT_PAGE_ID) {
236
+ // 페이지
237
+ const page = await createTemplatedPage(ctx.notion, ctx.env.NOTION_ROOT_PAGE_ID, tmpl.notion_template, title, issueUrl, severity);
238
+ notionUrl = page.url;
239
+ console.log(`[Notion] 버그리포트 페이지 생성: ${notionUrl}`);
240
+ }
241
+ else {
242
+ console.log("[Notion] NOTION_ROOT_PAGE_ID 미설정 — 문서 생성 생략");
243
+ return;
244
+ }
245
+ // 3. Link
246
+ await createAttachment(ctx.linear, issue.id, notionUrl, `${title} — Bug Report`);
247
+ console.log(`[Link] Linear ↔ Notion 연결 완료`);
248
+ console.log(`\n✅ 버그 리포트: ${issueId} | Notion: ${notionUrl}`);
249
+ }
250
+ async function addTask(ctx, args) {
251
+ const parentIdentifier = args._[0];
252
+ const title = args._[1];
253
+ if (!parentIdentifier || !title) {
254
+ throw new Error("사용법: add-task <부모이슈> <제목>");
255
+ }
256
+ const parent = await getIssue(ctx.linear, parentIdentifier);
257
+ const child = await createIssue(ctx.linear, {
258
+ teamId: ctx.env.LINEAR_DEFAULT_TEAM_ID,
259
+ title,
260
+ parentId: parent.id,
261
+ projectId: ctx.env.LINEAR_DEFAULT_PROJECT_ID,
262
+ });
263
+ console.log(`✅ 하위 이슈 생성: ${child.identifier} (부모: ${parent.identifier})`);
264
+ }
265
+ async function relate(ctx, args) {
266
+ const id1 = args._[0];
267
+ const id2 = args._[1];
268
+ if (!id1 || !id2) {
269
+ throw new Error("사용법: relate <이슈1> <이슈2> [--type related]");
270
+ }
271
+ const type = args.type ?? "related";
272
+ if (type !== "related" && type !== "similar") {
273
+ throw new Error(`relate 커맨드는 'related' 또는 'similar' 타입만 지원합니다. blocks 관계는 'block' 커맨드를 사용하세요.`);
274
+ }
275
+ const issue1 = await getIssue(ctx.linear, id1);
276
+ const issue2 = await getIssue(ctx.linear, id2);
277
+ await createRelation(ctx.linear, issue1.id, issue2.id, type);
278
+ console.log(`✅ ${id1} ↔ ${id2} (${type}) 관계 생성 완료`);
279
+ }
280
+ async function block(ctx, args) {
281
+ const id1 = args._[0];
282
+ const id2 = args._[1];
283
+ if (!id1 || !id2) {
284
+ throw new Error("사용법: block <선행이슈> <후행이슈>");
285
+ }
286
+ const issue1 = await getIssue(ctx.linear, id1);
287
+ const issue2 = await getIssue(ctx.linear, id2);
288
+ await createRelation(ctx.linear, issue1.id, issue2.id, "blocks");
289
+ console.log(`✅ ${id1}이 ${id2}를 선행합니다 (blocks)`);
290
+ }
291
+ async function attachDoc(ctx, args) {
292
+ const identifier = args._[0];
293
+ const url = args.url;
294
+ const title = args.title;
295
+ const type = args.type;
296
+ if (!identifier || !url || !title || !type) {
297
+ throw new Error('사용법: attach-doc <이슈> --url "URL" --title "제목" --type <유형>');
298
+ }
299
+ validateDocType(ctx.config, type);
300
+ const issue = await getIssue(ctx.linear, identifier);
301
+ await createAttachment(ctx.linear, issue.id, url, title, type);
302
+ console.log(`✅ ${identifier}에 문서 첨부: "${title}" (${type}) — ${url}`);
303
+ }
304
+ async function get(ctx, args) {
305
+ const identifier = args._[0];
306
+ if (!identifier) {
307
+ throw new Error("사용법: get <이슈>");
308
+ }
309
+ const detail = await getIssueDetail(ctx.linear, identifier);
310
+ const { issue, children, relations, attachments } = detail;
311
+ const state = await issue.state;
312
+ const labels = await issue.labels();
313
+ const labelNames = labels.nodes.map((l) => l.name).join(", ");
314
+ console.log(`\n${issue.identifier}: ${issue.title}`);
315
+ console.log(`상태: ${state?.name ?? "?"} | 우선순위: ${issue.priority ?? "?"} | 라벨: ${labelNames || "없음"}`);
316
+ if (children.length > 0) {
317
+ console.log("\n하위 이슈:");
318
+ for (const child of children) {
319
+ const childState = await child.state;
320
+ console.log(` ${child.identifier}: ${child.title} (${childState?.name ?? "?"})`);
321
+ }
322
+ }
323
+ if (relations.length > 0) {
324
+ console.log("\n관계:");
325
+ for (const rel of relations) {
326
+ const arrow = rel.type === "blocks" ? "→ blocks" : "↔ related";
327
+ console.log(` ${arrow} ${rel.issue.identifier} (${rel.issue.title})`);
328
+ }
329
+ }
330
+ if (attachments.length > 0) {
331
+ console.log("\n첨부 문서:");
332
+ for (const att of attachments) {
333
+ const typeLabel = att.subtitle ? ` (${att.subtitle})` : "";
334
+ console.log(` 📄 ${att.title}${typeLabel} — ${att.url}`);
335
+ }
336
+ }
337
+ }
338
+ // ── Command Registry ──
339
+ const COMMANDS = {
340
+ setup: (ctx) => setup(ctx),
341
+ "start-feature": startFeature,
342
+ "report-bug": reportBug,
343
+ "add-task": addTask,
344
+ relate,
345
+ block,
346
+ "attach-doc": attachDoc,
347
+ get,
348
+ };
349
+ // ── Main ──
350
+ async function main() {
351
+ const args = minimist(process.argv.slice(2), {
352
+ string: ["severity", "type", "url", "title", "linear-key", "notion-key", "team-id", "project-id", "notion-page"],
353
+ boolean: ["global"],
354
+ alias: { s: "severity", t: "type" },
355
+ });
356
+ const command = args._[0];
357
+ args._ = args._.slice(1); // command 제거, 나머지가 positional args
358
+ if (!command || command === "help") {
359
+ console.log(`pm-skill — Structured project management CLI (Linear + Notion)
360
+
361
+ Usage: pm-skill <command> [args] [flags]
362
+
363
+ Commands:
364
+ init --linear-key K [--notion-key K] [--global]
365
+ Initialize config & validate API keys
366
+ setup Verify Linear/Notion connection & show config
367
+ start-feature <title> Start feature (Linear issue + Notion PRD)
368
+ report-bug <title> [--severity S] File bug report (severity: urgent/high/medium/low)
369
+ add-task <parent> <title> Add sub-task to an issue
370
+ relate <issue1> <issue2> [--type T] Link issues (type: related/similar)
371
+ block <blocker> <blocked> Set blocking relationship
372
+ attach-doc <issue> --url U --title T --type Y
373
+ Attach document (type: source-of-truth/issue-tracking/domain-knowledge)
374
+ get <issue> Show issue details
375
+ help Show this help
376
+
377
+ Config lookup: CWD/.env + ~/.pm-skill/.env (both loaded, CWD wins)`);
378
+ return;
379
+ }
380
+ // init runs independently — no env/config validation
381
+ if (command === "init") {
382
+ await init(args);
383
+ return;
384
+ }
385
+ const cmdFn = COMMANDS[command];
386
+ if (!cmdFn) {
387
+ const available = ["init", ...Object.keys(COMMANDS)].join(", ");
388
+ throw new Error(`Unknown command: '${command}'\nAvailable: ${available}`);
389
+ }
390
+ // Validate env
391
+ const env = validateEnv(command);
392
+ // Load & validate config
393
+ const config = loadConfig();
394
+ // Build context
395
+ const linear = getLinearClient(env.LINEAR_API_KEY);
396
+ const notion = env.NOTION_API_KEY
397
+ ? getNotionClient(env.NOTION_API_KEY)
398
+ : null;
399
+ const ctx = { config, linear, notion, env };
400
+ await cmdFn(ctx, args);
401
+ }
402
+ main().catch((err) => {
403
+ console.error(`\n❌ ${err.message}`);
404
+ process.exit(1);
405
+ });
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "pm-skill",
3
+ "version": "1.0.0",
4
+ "description": "Structured project management CLI — Linear + Notion integration for AI coding assistants (Claude Code, Codex)",
5
+ "type": "module",
6
+ "bin": {
7
+ "pm-skill": "./dist/workflows.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "start": "npx tsx src/workflows.ts",
12
+ "typecheck": "tsc --noEmit",
13
+ "prepublishOnly": "npm run build",
14
+ "release:patch": "npm version patch && npm publish && git push && git push --tags",
15
+ "release:minor": "npm version minor && npm publish && git push && git push --tags",
16
+ "release:major": "npm version major && npm publish && git push && git push --tags"
17
+ },
18
+ "keywords": [
19
+ "linear",
20
+ "notion",
21
+ "project-management",
22
+ "cli",
23
+ "claude-code",
24
+ "codex",
25
+ "ai-assistant",
26
+ "skill"
27
+ ],
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/Leonamin/pm-skill.git"
32
+ },
33
+ "homepage": "https://github.com/Leonamin/pm-skill#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/Leonamin/pm-skill/issues"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "config.yml",
40
+ ".env.example",
41
+ "SKILL.md",
42
+ "AGENTS.md",
43
+ "README.md"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "dependencies": {
49
+ "@linear/sdk": "^80",
50
+ "@notionhq/client": "^5",
51
+ "js-yaml": "^4",
52
+ "minimist": "^1"
53
+ },
54
+ "devDependencies": {
55
+ "typescript": "^5",
56
+ "tsx": "^4",
57
+ "@types/js-yaml": "^4",
58
+ "@types/minimist": "^1",
59
+ "@types/node": "^22"
60
+ }
61
+ }