pmpt-cli 1.4.1 → 1.5.1

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/README.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  **Record and share your AI-driven product development journey.**
4
4
 
5
- AI와 대화하며 제품을 만드는 여정을 기록하고 공유하세요.
6
-
7
5
  [![npm version](https://img.shields.io/npm/v/pmpt-cli.svg)](https://www.npmjs.com/package/pmpt-cli)
8
6
 
9
7
  **Website**: [pmptwiki.com](https://pmptwiki.com)
@@ -44,7 +42,7 @@ pmpt publish
44
42
  - **5 questions** — Quick product planning with AI-ready prompts
45
43
  - **Version history** — Track every step of your AI-assisted development
46
44
  - **Share & reproduce** — Publish projects for others to learn from and clone
47
- - **Project hub** — Browse and clone projects at [pmptwiki.com](https://pmptwiki.com/en/explore)
45
+ - **Project hub** — Browse and clone projects at [pmptwiki.com](https://pmptwiki.com/explore)
48
46
 
49
47
  ---
50
48
 
@@ -141,7 +139,7 @@ Single JSON file containing your entire development journey:
141
139
  ## Links
142
140
 
143
141
  - [Website](https://pmptwiki.com)
144
- - [Explore Projects](https://pmptwiki.com/en/explore)
142
+ - [Explore Projects](https://pmptwiki.com/explore)
145
143
  - [GitHub](https://github.com/pmptwiki/pmpt-cli)
146
144
  - [npm](https://www.npmjs.com/package/pmpt-cli)
147
145
 
@@ -3,27 +3,27 @@ import { fetchProjects } from '../lib/api.js';
3
3
  export async function cmdBrowse() {
4
4
  p.intro('pmpt browse');
5
5
  const s = p.spinner();
6
- s.start('프로젝트 목록 불러오는 중...');
6
+ s.start('Loading projects...');
7
7
  let projects;
8
8
  try {
9
9
  const index = await fetchProjects();
10
10
  projects = index.projects;
11
11
  }
12
12
  catch (err) {
13
- s.stop('불러오기 실패');
14
- p.log.error(err instanceof Error ? err.message : '프로젝트 목록을 불러올 없습니다.');
13
+ s.stop('Failed to load');
14
+ p.log.error(err instanceof Error ? err.message : 'Could not load project list.');
15
15
  process.exit(1);
16
16
  }
17
- s.stop(`${projects.length} 프로젝트`);
17
+ s.stop(`${projects.length} projects`);
18
18
  if (projects.length === 0) {
19
- p.log.info('아직 공개된 프로젝트가 없습니다.');
20
- p.log.message(' pmpt publish — 번째 프로젝트를 공유해보세요!');
19
+ p.log.info('No published projects yet.');
20
+ p.log.message(' pmpt publish — share your first project!');
21
21
  p.outro('');
22
22
  return;
23
23
  }
24
24
  // Select project
25
25
  const selected = await p.select({
26
- message: '프로젝트를 선택하세요:',
26
+ message: 'Select a project:',
27
27
  options: projects.map((proj) => ({
28
28
  value: proj.slug,
29
29
  label: proj.projectName,
@@ -47,11 +47,11 @@ export async function cmdBrowse() {
47
47
  ].filter(Boolean).join('\n'), 'Project Details');
48
48
  // Action
49
49
  const action = await p.select({
50
- message: '어떻게 할까요?',
50
+ message: 'What would you like to do?',
51
51
  options: [
52
- { value: 'clone', label: ' 프로젝트 복제', hint: 'pmpt clone' },
53
- { value: 'url', label: 'URL 표시', hint: '브라우저에서 보기' },
54
- { value: 'back', label: '돌아가기' },
52
+ { value: 'clone', label: 'Clone this project', hint: 'pmpt clone' },
53
+ { value: 'url', label: 'Show URL', hint: 'View in browser' },
54
+ { value: 'back', label: 'Go back' },
55
55
  ],
56
56
  });
57
57
  if (p.isCancel(action) || action === 'back') {
@@ -67,7 +67,7 @@ export async function cmdBrowse() {
67
67
  const url = `https://pmptwiki.com/ko/p/${project.slug}`;
68
68
  p.log.info(`URL: ${url}`);
69
69
  p.log.message(`Download: ${project.downloadUrl}`);
70
- p.log.message(`\npmpt clone ${project.slug} — 터미널에서 복제`);
70
+ p.log.message(`\npmpt clone ${project.slug} — clone via terminal`);
71
71
  p.outro('');
72
72
  }
73
73
  }
@@ -46,31 +46,31 @@ export function restoreDocs(docsDir, docs) {
46
46
  }
47
47
  export async function cmdClone(slug) {
48
48
  if (!slug) {
49
- p.log.error('slug를 입력하세요.');
50
- p.log.info('사용법: pmpt clone <slug>');
49
+ p.log.error('Please provide a slug.');
50
+ p.log.info('Usage: pmpt clone <slug>');
51
51
  process.exit(1);
52
52
  }
53
53
  p.intro(`pmpt clone — ${slug}`);
54
54
  const s = p.spinner();
55
- s.start('프로젝트 다운로드 중...');
55
+ s.start('Downloading project...');
56
56
  let fileContent;
57
57
  try {
58
58
  fileContent = await fetchPmptFile(slug);
59
59
  }
60
60
  catch (err) {
61
- s.stop('다운로드 실패');
62
- p.log.error(err instanceof Error ? err.message : '프로젝트를 찾을 수 없습니다.');
61
+ s.stop('Download failed');
62
+ p.log.error(err instanceof Error ? err.message : 'Project not found.');
63
63
  process.exit(1);
64
64
  }
65
- s.message('검증 중...');
65
+ s.message('Validating...');
66
66
  const validation = validatePmptFile(fileContent);
67
67
  if (!validation.success || !validation.data) {
68
- s.stop('검증 실패');
69
- p.log.error(validation.error || '잘못된 .pmpt 파일입니다.');
68
+ s.stop('Validation failed');
69
+ p.log.error(validation.error || 'Invalid .pmpt file.');
70
70
  process.exit(1);
71
71
  }
72
72
  const pmptData = validation.data;
73
- s.stop('다운로드 완료');
73
+ s.stop('Download complete');
74
74
  // Show summary
75
75
  p.note([
76
76
  `Project: ${pmptData.meta.projectName}`,
@@ -81,16 +81,16 @@ export async function cmdClone(slug) {
81
81
  const projectPath = process.cwd();
82
82
  if (isInitialized(projectPath)) {
83
83
  const overwrite = await p.confirm({
84
- message: '이미 초기화된 프로젝트입니다. 히스토리를 병합하시겠습니까?',
84
+ message: 'Project already initialized. Merge history?',
85
85
  initialValue: true,
86
86
  });
87
87
  if (p.isCancel(overwrite) || !overwrite) {
88
- p.cancel('취소됨');
88
+ p.cancel('Cancelled');
89
89
  process.exit(0);
90
90
  }
91
91
  }
92
92
  const importSpinner = p.spinner();
93
- importSpinner.start('프로젝트 복원 중...');
93
+ importSpinner.start('Restoring project...');
94
94
  if (!isInitialized(projectPath)) {
95
95
  initializeProject(projectPath, { trackGit: true });
96
96
  }
@@ -113,15 +113,15 @@ export async function cmdClone(slug) {
113
113
  if (existsSync(historyDir)) {
114
114
  versionCount = readdirSync(historyDir).filter((d) => d.startsWith('v')).length;
115
115
  }
116
- importSpinner.stop('복원 완료!');
116
+ importSpinner.stop('Restore complete!');
117
117
  p.note([
118
118
  `Project: ${pmptData.meta.projectName}`,
119
119
  `Versions: ${versionCount}`,
120
120
  `Location: ${pmptDir}`,
121
121
  ].join('\n'), 'Clone Summary');
122
- p.log.info('다음 단계:');
123
- p.log.message(' pmpt history — 버전 히스토리 보기');
124
- p.log.message(' pmpt plan — AI 프롬프트 보기');
125
- p.log.message(' pmpt save — 스냅샷 저장');
126
- p.outro('프로젝트가 복제되었습니다!');
122
+ p.log.info('Next steps:');
123
+ p.log.message(' pmpt history — view version history');
124
+ p.log.message(' pmpt plan — view AI prompt');
125
+ p.log.message(' pmpt save — save a new snapshot');
126
+ p.outro('Project cloned!');
127
127
  }
@@ -2,29 +2,10 @@ import * as p from '@clack/prompts';
2
2
  import { resolve, join, basename } from 'path';
3
3
  import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
4
4
  import { isInitialized, getDocsDir, loadConfig } from '../lib/config.js';
5
- import { getAllSnapshots } from '../lib/history.js';
5
+ import { getAllSnapshots, resolveFullSnapshot } from '../lib/history.js';
6
6
  import { getPlanProgress } from '../lib/plan.js';
7
7
  import { createPmptFile, SCHEMA_VERSION } from '../lib/pmptFile.js';
8
8
  import glob from 'fast-glob';
9
- /**
10
- * Read all files from a snapshot directory
11
- */
12
- function readSnapshotFiles(snapshotDir) {
13
- const files = {};
14
- if (!existsSync(snapshotDir))
15
- return files;
16
- const mdFiles = glob.sync('**/*.md', { cwd: snapshotDir });
17
- for (const file of mdFiles) {
18
- const filePath = join(snapshotDir, file);
19
- try {
20
- files[file] = readFileSync(filePath, 'utf-8');
21
- }
22
- catch {
23
- // Skip files that can't be read
24
- }
25
- }
26
- return files;
27
- }
28
9
  /**
29
10
  * Read current docs folder
30
11
  */
@@ -69,15 +50,15 @@ export async function cmdExport(path, options) {
69
50
  : resolve(projectPath, `${exportName}.pmpt`);
70
51
  const s = p.spinner();
71
52
  s.start('Creating .pmpt file...');
72
- // Build history array with file contents
53
+ // Build history array with file contents (resolve from optimized snapshots)
73
54
  const history = [];
74
- for (const snapshot of snapshots) {
75
- const files = readSnapshotFiles(snapshot.snapshotDir);
55
+ for (let i = 0; i < snapshots.length; i++) {
56
+ const files = resolveFullSnapshot(snapshots, i);
76
57
  history.push({
77
- version: snapshot.version,
78
- timestamp: snapshot.timestamp,
58
+ version: snapshots[i].version,
59
+ timestamp: snapshots[i].timestamp,
79
60
  files,
80
- git: snapshot.git,
61
+ git: snapshots[i].git,
81
62
  });
82
63
  }
83
64
  // Read current docs
@@ -1,8 +1,7 @@
1
1
  import * as p from '@clack/prompts';
2
- import { resolve, join } from 'path';
3
- import { readFileSync, existsSync } from 'fs';
2
+ import { resolve } from 'path';
4
3
  import { isInitialized } from '../lib/config.js';
5
- import { getAllSnapshots } from '../lib/history.js';
4
+ import { getAllSnapshots, resolveFileContent } from '../lib/history.js';
6
5
  // Simple diff calculation: count changed lines
7
6
  function calculateDiffSize(oldContent, newContent) {
8
7
  const oldLines = oldContent.split('\n');
@@ -16,15 +15,15 @@ function calculateDiffSize(oldContent, newContent) {
16
15
  }
17
16
  return changes;
18
17
  }
19
- // Get total diff between two snapshots
20
- function getSnapshotDiff(prev, curr) {
18
+ // Get total diff between two snapshots (supports optimized snapshots)
19
+ function getSnapshotDiff(snapshots, prevIndex, currIndex) {
20
+ const prev = snapshots[prevIndex];
21
+ const curr = snapshots[currIndex];
21
22
  let totalChanges = 0;
22
23
  const allFiles = new Set([...prev.files, ...curr.files]);
23
24
  for (const file of allFiles) {
24
- const prevPath = join(prev.snapshotDir, file);
25
- const currPath = join(curr.snapshotDir, file);
26
- const prevContent = existsSync(prevPath) ? readFileSync(prevPath, 'utf-8') : '';
27
- const currContent = existsSync(currPath) ? readFileSync(currPath, 'utf-8') : '';
25
+ const prevContent = resolveFileContent(snapshots, prevIndex, file) || '';
26
+ const currContent = resolveFileContent(snapshots, currIndex, file) || '';
28
27
  totalChanges += calculateDiffSize(prevContent, currContent);
29
28
  }
30
29
  return totalChanges;
@@ -49,7 +48,7 @@ export function cmdHistory(path, options) {
49
48
  if (options?.compact && snapshots.length > 1) {
50
49
  displaySnapshots = [snapshots[0]]; // Always show first
51
50
  for (let i = 1; i < snapshots.length; i++) {
52
- const diffSize = getSnapshotDiff(snapshots[i - 1], snapshots[i]);
51
+ const diffSize = getSnapshotDiff(snapshots, i - 1, i);
53
52
  // Threshold: hide if less than 5 lines changed
54
53
  if (diffSize < 5) {
55
54
  hiddenVersions.push(snapshots[i].version);
@@ -1,6 +1,7 @@
1
1
  import * as p from '@clack/prompts';
2
+ import open from 'open';
2
3
  import { loadAuth, saveAuth } from '../lib/auth.js';
3
- import { registerAuth } from '../lib/api.js';
4
+ import { requestDeviceCode, pollDeviceToken } from '../lib/api.js';
4
5
  export async function cmdLogin() {
5
6
  p.intro('pmpt login');
6
7
  const existing = loadAuth();
@@ -15,32 +16,63 @@ export async function cmdLogin() {
15
16
  return;
16
17
  }
17
18
  }
18
- p.log.info('GitHub Personal Access Token이 필요합니다.\n' +
19
- ' https://github.com/settings/tokens/new\n' +
20
- ' 필요 권한: read:user');
21
- const pat = await p.password({
22
- message: 'GitHub PAT를 입력하세요:',
23
- validate: (v) => (v.trim().length < 10 ? '올바른 토큰을 입력하세요' : undefined),
24
- });
25
- if (p.isCancel(pat)) {
26
- p.cancel('취소됨');
27
- process.exit(0);
28
- }
19
+ // Step 1: Request device code
29
20
  const s = p.spinner();
30
- s.start('인증 중...');
21
+ s.start('Preparing GitHub authentication...');
22
+ let device;
31
23
  try {
32
- const result = await registerAuth(pat);
33
- saveAuth({
34
- token: result.token,
35
- githubToken: pat,
36
- username: result.username,
37
- });
38
- s.stop(`인증 완료 — @${result.username}`);
24
+ device = await requestDeviceCode();
25
+ s.stop('Verification code issued.');
39
26
  }
40
27
  catch (err) {
41
- s.stop('인증 실패');
42
- p.log.error(err instanceof Error ? err.message : '인증에 실패했습니다.');
28
+ s.stop('Failed to issue verification code');
29
+ p.log.error(err instanceof Error ? err.message : 'Failed to prepare authentication.');
43
30
  process.exit(1);
44
31
  }
45
- p.outro('로그인 완료! pmpt publish로 프로젝트를 공유하세요.');
32
+ // Step 2: Show code and open browser
33
+ p.log.info(`Enter this code on GitHub:\n\n` +
34
+ ` Code: ${device.userCode}\n` +
35
+ ` URL: ${device.verificationUri}`);
36
+ const shouldOpen = await p.confirm({
37
+ message: 'Open browser?',
38
+ initialValue: true,
39
+ });
40
+ if (p.isCancel(shouldOpen)) {
41
+ p.cancel('Cancelled');
42
+ process.exit(0);
43
+ }
44
+ if (shouldOpen) {
45
+ await open(device.verificationUri);
46
+ }
47
+ // Step 3: Poll for token
48
+ s.start('Waiting for GitHub authorization... (enter the code in your browser)');
49
+ let interval = device.interval * 1000; // seconds → ms
50
+ const deadline = Date.now() + device.expiresIn * 1000;
51
+ while (Date.now() < deadline) {
52
+ await sleep(interval);
53
+ try {
54
+ const result = await pollDeviceToken(device.deviceCode);
55
+ if (result.status === 'complete') {
56
+ saveAuth({ token: result.token, username: result.username });
57
+ s.stop(`Authenticated — @${result.username}`);
58
+ p.outro('Login complete! Build projects with AI using pmpt and share your vibe coding journey on pmptwiki.');
59
+ return;
60
+ }
61
+ if (result.status === 'slow_down') {
62
+ interval = (result.interval ?? 10) * 1000;
63
+ }
64
+ // status === 'pending' → keep polling
65
+ }
66
+ catch (err) {
67
+ s.stop('Authentication failed');
68
+ p.log.error(err instanceof Error ? err.message : 'Authentication failed.');
69
+ process.exit(1);
70
+ }
71
+ }
72
+ s.stop('Verification code expired.');
73
+ p.log.error('Please run pmpt login again.');
74
+ process.exit(1);
75
+ }
76
+ function sleep(ms) {
77
+ return new Promise((resolve) => setTimeout(resolve, ms));
46
78
  }
@@ -3,55 +3,55 @@ import { writeFileSync, mkdirSync } from 'fs';
3
3
  import { dirname } from 'path';
4
4
  import { generateContent, generateFilePath } from '../lib/template.js';
5
5
  export async function cmdNew() {
6
- p.intro('pmptwiki — 문서 만들기');
6
+ p.intro('pmptwiki — create new document');
7
7
  const answers = await p.group({
8
8
  lang: () => p.select({
9
- message: '언어를 선택하세요',
9
+ message: 'Select language',
10
10
  options: [
11
- { value: 'ko', label: '한국어 (ko)' },
11
+ { value: 'ko', label: 'Korean (ko)' },
12
12
  { value: 'en', label: 'English (en)' },
13
13
  ],
14
14
  }),
15
15
  purpose: () => p.select({
16
- message: '문서 유형을 선택하세요',
16
+ message: 'Select document type',
17
17
  options: [
18
- { value: 'guide', label: '가이드', hint: '개념 설명 + 방법' },
19
- { value: 'rule', label: '규칙', hint: '해야 할 것 / 하지 말 것' },
20
- { value: 'template', label: '템플릿', hint: '복사해서 쓰는 프롬프트' },
21
- { value: 'example', label: '사례', hint: '실제 사용 사례' },
22
- { value: 'reference', label: '레퍼런스', hint: '참고 자료 모음' },
18
+ { value: 'guide', label: 'Guide', hint: 'Concept explanation + how-to' },
19
+ { value: 'rule', label: 'Rule', hint: 'Do / Don\'t' },
20
+ { value: 'template', label: 'Template', hint: 'Copy-paste prompt' },
21
+ { value: 'example', label: 'Example', hint: 'Real-world use case' },
22
+ { value: 'reference', label: 'Reference', hint: 'Resource collection' },
23
23
  ],
24
24
  }),
25
25
  level: () => p.select({
26
- message: '난이도를 선택하세요',
26
+ message: 'Select difficulty',
27
27
  options: [
28
- { value: 'beginner', label: '입문' },
29
- { value: 'intermediate', label: '중급' },
30
- { value: 'advanced', label: '고급' },
28
+ { value: 'beginner', label: 'Beginner' },
29
+ { value: 'intermediate', label: 'Intermediate' },
30
+ { value: 'advanced', label: 'Advanced' },
31
31
  ],
32
32
  }),
33
33
  title: () => p.text({
34
- message: '제목을 입력하세요',
35
- placeholder: 'AI에게 충분한 배경을 주면 답변이 달라진다',
36
- validate: (v) => (v.trim().length < 5 ? '5 이상 입력해주세요' : undefined),
34
+ message: 'Enter a title',
35
+ placeholder: 'Providing enough context to AI changes the response',
36
+ validate: (v) => (v.trim().length < 5 ? 'At least 5 characters required' : undefined),
37
37
  }),
38
38
  tags: () => p.text({
39
- message: '태그를 입력하세요 (쉼표 구분, 선택)',
39
+ message: 'Enter tags (comma-separated, optional)',
40
40
  placeholder: 'context, beginner, prompt',
41
41
  }),
42
42
  persona: () => p.multiselect({
43
- message: '대상 독자를 선택하세요 (선택)',
43
+ message: 'Select target audience (optional)',
44
44
  options: [
45
- { value: 'general', label: '일반' },
46
- { value: 'power-user', label: '파워유저' },
47
- { value: 'developer', label: '개발자' },
48
- { value: 'organization', label: '조직' },
45
+ { value: 'general', label: 'General' },
46
+ { value: 'power-user', label: 'Power User' },
47
+ { value: 'developer', label: 'Developer' },
48
+ { value: 'organization', label: 'Organization' },
49
49
  ],
50
50
  required: false,
51
51
  }),
52
52
  }, {
53
53
  onCancel: () => {
54
- p.cancel('취소되었습니다');
54
+ p.cancel('Cancelled');
55
55
  process.exit(0);
56
56
  },
57
57
  });
@@ -69,10 +69,10 @@ export async function cmdNew() {
69
69
  const content = generateContent(fm);
70
70
  mkdirSync(dirname(filePath), { recursive: true });
71
71
  writeFileSync(filePath, content, 'utf-8');
72
- p.outro(`파일이 생성되었습니다: ${filePath}
72
+ p.outro(`File created: ${filePath}
73
73
 
74
- 다음 단계:
75
- 1. 파일을 열어 본문을 작성하세요
74
+ Next steps:
75
+ 1. Open the file and write the content
76
76
  2. pmpt validate ${filePath}
77
77
  3. pmpt submit ${filePath}`);
78
78
  }
@@ -2,26 +2,13 @@ import * as p from '@clack/prompts';
2
2
  import { resolve, basename } from 'path';
3
3
  import { readFileSync, existsSync } from 'fs';
4
4
  import { isInitialized, loadConfig, saveConfig, getDocsDir } from '../lib/config.js';
5
- import { getAllSnapshots } from '../lib/history.js';
5
+ import { getAllSnapshots, resolveFullSnapshot } from '../lib/history.js';
6
6
  import { getPlanProgress } from '../lib/plan.js';
7
7
  import { createPmptFile } from '../lib/pmptFile.js';
8
8
  import { loadAuth } from '../lib/auth.js';
9
9
  import { publishProject } from '../lib/api.js';
10
10
  import glob from 'fast-glob';
11
11
  import { join } from 'path';
12
- function readSnapshotFiles(snapshotDir) {
13
- const files = {};
14
- if (!existsSync(snapshotDir))
15
- return files;
16
- const mdFiles = glob.sync('**/*.md', { cwd: snapshotDir });
17
- for (const file of mdFiles) {
18
- try {
19
- files[file] = readFileSync(join(snapshotDir, file), 'utf-8');
20
- }
21
- catch { /* skip */ }
22
- }
23
- return files;
24
- }
25
12
  function readDocsFolder(docsDir) {
26
13
  const files = {};
27
14
  if (!existsSync(docsDir))
@@ -38,12 +25,12 @@ function readDocsFolder(docsDir) {
38
25
  export async function cmdPublish(path) {
39
26
  const projectPath = path ? resolve(path) : process.cwd();
40
27
  if (!isInitialized(projectPath)) {
41
- p.log.error('프로젝트가 초기화되지 않았습니다. `pmpt init`을 먼저 실행하세요.');
28
+ p.log.error('Project not initialized. Run `pmpt init` first.');
42
29
  process.exit(1);
43
30
  }
44
31
  const auth = loadAuth();
45
32
  if (!auth?.token || !auth?.username) {
46
- p.log.error('로그인이 필요합니다. `pmpt login`을 먼저 실행하세요.');
33
+ p.log.error('Login required. Run `pmpt login` first.');
47
34
  process.exit(1);
48
35
  }
49
36
  p.intro('pmpt publish');
@@ -51,52 +38,69 @@ export async function cmdPublish(path) {
51
38
  const snapshots = getAllSnapshots(projectPath);
52
39
  const planProgress = getPlanProgress(projectPath);
53
40
  if (snapshots.length === 0) {
54
- p.log.warn('스냅샷이 없습니다. `pmpt save` 또는 `pmpt plan`을 먼저 실행하세요.');
41
+ p.log.warn('No snapshots found. Run `pmpt save` or `pmpt plan` first.');
55
42
  p.outro('');
56
43
  return;
57
44
  }
58
45
  const projectName = planProgress?.answers?.projectName || basename(projectPath);
59
46
  // Collect publish info
60
47
  const slug = await p.text({
61
- message: '프로젝트 slug (URL에 사용될 이름):',
48
+ message: 'Project slug (used in URL):',
62
49
  placeholder: projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-'),
63
50
  validate: (v) => {
64
51
  if (!/^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/.test(v)) {
65
- return '3~50자, 소문자/숫자/하이픈만 사용 가능합니다.';
52
+ return '3-50 chars, lowercase letters, numbers, and hyphens only.';
66
53
  }
67
54
  },
68
55
  });
69
56
  if (p.isCancel(slug)) {
70
- p.cancel('취소됨');
57
+ p.cancel('Cancelled');
71
58
  process.exit(0);
72
59
  }
73
60
  const description = await p.text({
74
- message: '프로젝트 설명 (짧게):',
61
+ message: 'Project description (brief):',
75
62
  placeholder: planProgress?.answers?.productIdea?.slice(0, 100) || '',
76
63
  defaultValue: planProgress?.answers?.productIdea?.slice(0, 200) || '',
77
64
  });
78
65
  if (p.isCancel(description)) {
79
- p.cancel('취소됨');
66
+ p.cancel('Cancelled');
80
67
  process.exit(0);
81
68
  }
82
69
  const tagsInput = await p.text({
83
- message: '태그 (쉼표로 구분):',
70
+ message: 'Tags (comma-separated):',
84
71
  placeholder: 'react, saas, mvp',
85
72
  defaultValue: '',
86
73
  });
87
74
  if (p.isCancel(tagsInput)) {
88
- p.cancel('취소됨');
75
+ p.cancel('Cancelled');
89
76
  process.exit(0);
90
77
  }
91
78
  const tags = tagsInput
92
79
  .split(',')
93
80
  .map((t) => t.trim().toLowerCase())
94
81
  .filter(Boolean);
95
- // Build .pmpt content (reuse export logic)
96
- const history = snapshots.map((snapshot) => ({
82
+ const category = await p.select({
83
+ message: 'Project category:',
84
+ options: [
85
+ { value: 'web-app', label: 'Web App' },
86
+ { value: 'mobile-app', label: 'Mobile App' },
87
+ { value: 'cli-tool', label: 'CLI Tool' },
88
+ { value: 'api-backend', label: 'API/Backend' },
89
+ { value: 'ai-ml', label: 'AI/ML' },
90
+ { value: 'game', label: 'Game' },
91
+ { value: 'library', label: 'Library' },
92
+ { value: 'other', label: 'Other' },
93
+ ],
94
+ });
95
+ if (p.isCancel(category)) {
96
+ p.cancel('Cancelled');
97
+ process.exit(0);
98
+ }
99
+ // Build .pmpt content (resolve from optimized snapshots)
100
+ const history = snapshots.map((snapshot, i) => ({
97
101
  version: snapshot.version,
98
102
  timestamp: snapshot.timestamp,
99
- files: readSnapshotFiles(snapshot.snapshotDir),
103
+ files: resolveFullSnapshot(snapshots, i),
100
104
  git: snapshot.git,
101
105
  }));
102
106
  const docsDir = getDocsDir(projectPath);
@@ -125,27 +129,29 @@ export async function cmdPublish(path) {
125
129
  `Versions: ${snapshots.length}`,
126
130
  `Size: ${(pmptContent.length / 1024).toFixed(1)} KB`,
127
131
  `Author: @${auth.username}`,
132
+ `Category: ${category}`,
128
133
  tags.length ? `Tags: ${tags.join(', ')}` : '',
129
134
  ].filter(Boolean).join('\n'), 'Publish Preview');
130
135
  const confirm = await p.confirm({
131
- message: '게시하시겠습니까?',
136
+ message: 'Publish this project?',
132
137
  initialValue: true,
133
138
  });
134
139
  if (p.isCancel(confirm) || !confirm) {
135
- p.cancel('취소됨');
140
+ p.cancel('Cancelled');
136
141
  process.exit(0);
137
142
  }
138
143
  // Upload
139
144
  const s = p.spinner();
140
- s.start('업로드 중...');
145
+ s.start('Uploading...');
141
146
  try {
142
147
  const result = await publishProject(auth.token, {
143
148
  slug: slug,
144
149
  pmptContent,
145
150
  description: description,
146
151
  tags,
152
+ category: category,
147
153
  });
148
- s.stop('게시 완료!');
154
+ s.stop('Published!');
149
155
  // Update config
150
156
  if (config) {
151
157
  config.lastPublished = new Date().toISOString();
@@ -155,12 +161,12 @@ export async function cmdPublish(path) {
155
161
  `URL: ${result.url}`,
156
162
  `Download: ${result.downloadUrl}`,
157
163
  '',
158
- `pmpt clone ${slug} — 다른 사람이 프로젝트를 복제할 수 있습니다`,
164
+ `pmpt clone ${slug} — others can clone this project`,
159
165
  ].join('\n'), 'Published!');
160
166
  }
161
167
  catch (err) {
162
- s.stop('게시 실패');
163
- p.log.error(err instanceof Error ? err.message : '게시에 실패했습니다.');
168
+ s.stop('Publish failed');
169
+ p.log.error(err instanceof Error ? err.message : 'Failed to publish.');
164
170
  process.exit(1);
165
171
  }
166
172
  p.outro('');
@@ -32,11 +32,17 @@ export async function cmdSave(fileOrPath) {
32
32
  if (entry.git.dirty)
33
33
  msg += ' (uncommitted)';
34
34
  }
35
+ const changedCount = entry.changedFiles?.length ?? entry.files.length;
36
+ const unchangedCount = entry.files.length - changedCount;
37
+ if (unchangedCount > 0) {
38
+ msg += ` (${changedCount} changed, ${unchangedCount} skipped)`;
39
+ }
35
40
  p.log.success(msg);
36
41
  p.log.message('');
37
42
  p.log.info('Files included:');
38
43
  for (const file of entry.files) {
39
- p.log.message(` - ${file}`);
44
+ const isChanged = entry.changedFiles ? entry.changedFiles.includes(file) : true;
45
+ p.log.message(` - ${file}${isChanged ? '' : ' (unchanged)'}`);
40
46
  }
41
47
  }
42
48
  catch (error) {