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.
- package/.env.example +17 -0
- package/AGENTS.md +87 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/SKILL.md +134 -0
- package/config.yml +82 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.js +144 -0
- package/dist/env.d.ts +31 -0
- package/dist/env.js +153 -0
- package/dist/linear.d.ts +48 -0
- package/dist/linear.js +138 -0
- package/dist/notion.d.ts +34 -0
- package/dist/notion.js +225 -0
- package/dist/workflows.d.ts +2 -0
- package/dist/workflows.js +405 -0
- package/package.json +61 -0
|
@@ -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
|
+
}
|