@easysociable/cli 0.1.1 → 0.1.2

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.
Files changed (108) hide show
  1. package/README.md +4 -1
  2. package/dist/easysociable.js +301 -2829
  3. package/dist/easysociable.js.map +1 -1
  4. package/package.json +17 -12
  5. package/skills/ONBOARDING.md +3 -1
  6. package/skills/easysociable-content/INSTALL.md +60 -0
  7. package/skills/easysociable-content/SKILL.md +107 -96
  8. package/skills/easysociable-content/fixtures/golden-run-en/draft.md +13 -0
  9. package/skills/easysociable-content/fixtures/golden-run-en/idea.md +26 -0
  10. package/skills/easysociable-content/fixtures/golden-run-en/pack-tiktok.json +11 -0
  11. package/skills/easysociable-content/platforms/claude.md +1 -2
  12. package/skills/easysociable-content/platforms/codex.md +1 -2
  13. package/skills/easysociable-content/platforms/hermes.md +1 -2
  14. package/skills/easysociable-content/platforms/openclaw.md +1 -2
  15. package/skills/easysociable-content/references/CLI.md +25 -12
  16. package/skills/easysociable-content/references/CONTRACT_SYNC.md +42 -0
  17. package/skills/easysociable-content/references/MCP.md +7 -12
  18. package/skills/easysociable-content/references/PAGE_PACK.md +128 -5
  19. package/skills/easysociable-content/references/PLATFORM_GUIDE.md +36 -0
  20. package/skills/easysociable-content/references/RUN_LAYOUT.md +73 -0
  21. package/skills/easysociable-content/references/TROUBLESHOOTING.md +11 -9
  22. package/skills/easysociable-content/references/VAULT.md +32 -12
  23. package/skills/easysociable-content/references/VOICE.md +16 -0
  24. package/skills/easysociable-content/scripts/contracts.mjs +183 -0
  25. package/skills/easysociable-content/scripts/export-contracts.mjs +76 -0
  26. package/skills/easysociable-content/scripts/fixture.test.mjs +24 -0
  27. package/skills/easysociable-content/scripts/package-skill.mjs +49 -0
  28. package/skills/easysociable-content/scripts/setup.mjs +127 -83
  29. package/skills/easysociable-content/scripts/setup.test.mjs +49 -38
  30. package/skills/easysociable-content/scripts/starter.mjs +50 -48
  31. package/skills/easysociable-content/scripts/validate-export.mjs +42 -0
  32. package/skills/easysociable-content/scripts/validate-pack.mjs +48 -0
  33. package/skills/easysociable-content/scripts/validate-run.mjs +100 -0
  34. package/skills/easysociable-content/scripts/validate.test.mjs +101 -0
  35. package/skills/easysociable-content/scripts/workbench.mjs +135 -0
  36. package/skills/easysociable-content/starter/CLAUDE.md +756 -0
  37. package/skills/easysociable-content/starter/accounts/README.md +9 -0
  38. package/skills/easysociable-content/starter/accounts/_index.md +31 -0
  39. package/skills/easysociable-content/starter/accounts/overrides/README.md +3 -0
  40. package/skills/easysociable-content/starter/discovery/README.md +18 -0
  41. package/skills/easysociable-content/starter/discovery/_index.md +27 -0
  42. package/skills/easysociable-content/starter/discovery/_template.md +41 -0
  43. package/skills/easysociable-content/starter/engine.json +19 -0
  44. package/skills/easysociable-content/starter/exports/README.md +28 -0
  45. package/skills/easysociable-content/starter/inbox/README.md +9 -0
  46. package/skills/easysociable-content/starter/inbox/_index.md +24 -0
  47. package/skills/easysociable-content/starter/inbox/entries/README.md +3 -0
  48. package/skills/easysociable-content/starter/platforms/README.md +19 -0
  49. package/skills/easysociable-content/starter/platforms/ig.md +69 -0
  50. package/skills/easysociable-content/starter/platforms/instagram.md +5 -0
  51. package/skills/easysociable-content/starter/platforms/linkedin.md +72 -0
  52. package/skills/easysociable-content/starter/platforms/threads.md +69 -0
  53. package/skills/easysociable-content/starter/platforms/tiktok.md +71 -0
  54. package/skills/easysociable-content/starter/platforms/x.md +78 -0
  55. package/skills/easysociable-content/starter/platforms/xiaohongshu.md +19 -0
  56. package/skills/easysociable-content/starter/profiles/_index.md +13 -0
  57. package/skills/easysociable-content/starter/profiles/default/README.md +11 -0
  58. package/skills/easysociable-content/starter/profiles/default/audience.md +38 -0
  59. package/skills/easysociable-content/starter/profiles/default/pillars.md +33 -0
  60. package/skills/easysociable-content/starter/profiles/default/voice.md +24 -0
  61. package/skills/easysociable-content/starter/published/_index.md +20 -0
  62. package/skills/easysociable-content/starter/raw/README.md +4 -0
  63. package/skills/easysociable-content/starter/raw/_index.md +12 -0
  64. package/skills/easysociable-content/starter/runs/README.md +22 -0
  65. package/skills/easysociable-content/starter/runs/_index.md +41 -0
  66. package/skills/easysociable-content/starter/runs/_template/brief.md +66 -0
  67. package/skills/easysociable-content/starter/runs/_template/draft.md +11 -0
  68. package/skills/easysociable-content/starter/runs/_template/editor.md +18 -0
  69. package/skills/easysociable-content/starter/runs/_template/feedback.md +15 -0
  70. package/skills/easysociable-content/starter/runs/_template/idea.md +27 -0
  71. package/skills/easysociable-content/starter/runs/_template/pack.md +47 -0
  72. package/skills/easysociable-content/starter/runs/_template/packet.md +25 -0
  73. package/skills/easysociable-content/starter/runs/_template/rubric.md +19 -0
  74. package/skills/easysociable-content/starter/swipe/_index.md +27 -0
  75. package/skills/easysociable-content/starter/swipe/_template.md +34 -0
  76. package/skills/easysociable-content/starter/topics/README.md +25 -0
  77. package/skills/easysociable-content/starter/topics/WATCHLIST.md +65 -0
  78. package/skills/easysociable-content/starter/topics/_index.md +44 -0
  79. package/skills/easysociable-content/starter/topics/_template-item.md +90 -0
  80. package/skills/easysociable-content/starter/topics/items/README.md +4 -0
  81. package/skills/easysociable-content/starter/topics/scoring.md +201 -0
  82. package/skills/easysociable-content/starter/topics/watchlists/README.md +10 -0
  83. package/skills/easysociable-content/starter/topics/watchlists/_shared.md +14 -0
  84. package/skills/easysociable-content/starter/topics/watchlists/default.md +6 -0
  85. package/skills/easysociable-content/starter/wiki/README.md +13 -0
  86. package/skills/easysociable-content/starter/wiki/atoms/_index.md +23 -0
  87. package/skills/easysociable-content/starter/wiki/atoms/_template.md +31 -0
  88. package/skills/easysociable-content/starter/wiki/claims/_index.md +26 -0
  89. package/skills/easysociable-content/starter/wiki/claims/_template.md +38 -0
  90. package/skills/easysociable-content/starter/workbench/README.md +17 -0
  91. package/skills/easysociable-content/workbench/build.py +1074 -0
  92. package/skills/easysociable-content/workbench/serve.py +53 -0
  93. package/skills/easysociable-content/workbench/template.html +2119 -0
  94. package/skills/easysociable-content/workflows/ANALYSIS_AND_COVERAGE.md +14 -5
  95. package/skills/easysociable-content/workflows/CONTENT_EXPORT.md +16 -11
  96. package/skills/easysociable-content/workflows/GETTING_STARTED.md +40 -0
  97. package/skills/easysociable-content/workflows/MCP_FIRST.md +3 -8
  98. package/skills/easysociable-content/workflows/OUT_OF_SCOPE.md +18 -0
  99. package/skills/easysociable-content/workflows/PACK_AUTHORING.md +42 -0
  100. package/skills/easysociable-content/workflows/SWIPE_CAPTURE.md +18 -0
  101. package/skills/easysociable-content/workflows/TOPIC_DRAFT.md +28 -4
  102. package/skills/easysociable-content/workflows/WORKING_DRAFT.md +17 -8
  103. package/skills/easysociable-slideshow/SKILL.md +20 -13
  104. package/skills/easysociable-slideshow/references/VAULT.md +13 -11
  105. package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
  106. package/skills/easysociable-content/workflows/DEVICE_TASKS.md +0 -5
  107. package/skills/easysociable-content/workflows/EXPERIMENTS.md +0 -6
  108. package/skills/easysociable-content/workflows/PUBLICATION.md +0 -5
@@ -1,38 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
3
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
4
4
  import { homedir } from 'node:os'
5
- import { join, resolve } from 'node:path'
6
- import { spawnSync } from 'node:child_process'
7
- import { ROOT, filesFor } from './starter.mjs'
8
-
9
- const LOCALES = new Set(['en', 'zh'])
10
-
11
- function obsidianCandidates() {
12
- if (process.env.EASYSOCIABLE_OBSIDIAN_APP)
13
- return [process.env.EASYSOCIABLE_OBSIDIAN_APP]
14
- return [
15
- '/Applications/Obsidian.app',
16
- join(homedir(), 'Applications/Obsidian.app'),
17
- '/System/Applications/Obsidian.app',
18
- ].filter(Boolean)
19
- }
20
-
21
- function hasObsidian() {
22
- if (process.env.EASYSOCIABLE_SKIP_OBSIDIAN_CHECK === 'true') return true
23
- return obsidianCandidates().some((path) => existsSync(path))
24
- }
5
+ import { basename, join, resolve } from 'node:path'
6
+ import { ENGINE_ROOT, filesFor } from './starter.mjs'
7
+ import { buildWorkbench, ensureWorkbench, python3Bin, startWorkbench } from './workbench.mjs'
25
8
 
26
9
  function parseArgs(argv) {
27
- const options = { vault: '', locale: '', apply: false, validate: false, preview: false, noOpen: false, json: false }
10
+ const options = {
11
+ root: '', engine: '', apply: false, validate: false, preview: false,
12
+ detect: false, noOpen: false, json: false, workbench: false,
13
+ }
28
14
  for (let i = 0; i < argv.length; i += 1) {
29
15
  const arg = argv[i]
30
- if (arg === '--vault') options.vault = argv[++i] ?? ''
31
- else if (arg === '--locale') options.locale = argv[++i] ?? ''
16
+ if (arg === '--root') options.root = argv[++i] ?? ''
17
+ else if (arg === '--engine') options.engine = argv[++i] ?? ''
32
18
  else if (arg === '--apply') options.apply = true
33
19
  else if (arg === '--validate') options.validate = true
34
20
  else if (arg === '--preview') options.preview = true
21
+ else if (arg === '--detect') options.detect = true
35
22
  else if (arg === '--no-open') options.noOpen = true
23
+ else if (arg === '--workbench') options.workbench = true
36
24
  else if (arg === '--json') options.json = true
37
25
  else if (arg === '--help' || arg === '-h') options.help = true
38
26
  else throw new Error(`Unknown argument: ${arg}`)
@@ -48,76 +36,123 @@ function isDir(path) {
48
36
  return existsSync(path) && statSync(path).isDirectory()
49
37
  }
50
38
 
51
- function enginePaths(vault) {
52
- return { en: join(vault, ROOT.en), zh: join(vault, ROOT.zh) }
39
+ function isEngine(path) {
40
+ return isDir(path) && existsSync(join(path, 'CLAUDE.md')) && isDir(join(path, 'runs'))
53
41
  }
54
42
 
55
- function detectedLocale(vault) {
56
- const paths = enginePaths(vault)
57
- const en = isDir(paths.en)
58
- const zh = isDir(paths.zh)
59
- if (en && zh) return { code: 'BOTH_ENGINES', message: 'Vault has both Content Engine/ and 内容引擎/. Choose one folder before setup.' }
60
- if (zh) return { locale: 'zh' }
61
- if (en) return { locale: 'en' }
62
- return { locale: '' }
43
+ function resolveEngine(rootOrEngine) {
44
+ const absolute = resolve(rootOrEngine)
45
+ if (isEngine(absolute) || basename(absolute) === ENGINE_ROOT) return absolute
46
+ return join(absolute, ENGINE_ROOT)
63
47
  }
64
48
 
65
- function resolveLocale(vault, requested) {
66
- if (requested && !LOCALES.has(requested)) {
67
- return { code: 'LOCALE_INVALID', message: 'Choose --locale en or --locale zh.' }
68
- }
69
- const found = detectedLocale(vault)
70
- if (found.code) return found
71
- if (requested && found.locale && requested !== found.locale) {
72
- return { code: 'LOCALE_MISMATCH', message: `Vault already uses locale ${found.locale}. Do not create the other language tree.` }
73
- }
74
- const locale = requested || found.locale
75
- if (!locale) return { code: 'LOCALE_REQUIRED', message: 'Ask the user to choose 中文 (zh) or English (en), then pass --locale.' }
76
- return { locale }
49
+ function searchRoots() {
50
+ const extra = (process.env.EASYSOCIABLE_ENGINE_SEARCH_ROOTS || '')
51
+ .split(process.platform === 'win32' ? ';' : ':')
52
+ .map((item) => item.trim())
53
+ .filter(Boolean)
54
+ const home = homedir()
55
+ return [...extra, join(home, 'Documents'), home]
77
56
  }
78
57
 
79
- function inventory(root, locale) {
80
- const files = filesFor(locale)
81
- const engine = join(root, ROOT[locale])
58
+ function detectEngines() {
59
+ const found = []
60
+ for (const root of searchRoots()) {
61
+ if (!isDir(root)) continue
62
+ const direct = join(root, ENGINE_ROOT)
63
+ if (isEngine(direct) || isDir(direct)) found.push(direct)
64
+ try {
65
+ for (const name of readdirSync(root)) {
66
+ const child = join(root, name, ENGINE_ROOT)
67
+ if (isDir(child)) found.push(child)
68
+ }
69
+ }
70
+ catch {
71
+ continue
72
+ }
73
+ }
74
+ return [...new Set(found)]
75
+ }
76
+
77
+ function inventory(engine) {
78
+ const files = filesFor()
82
79
  const missing = []
83
80
  const existing = []
84
81
  for (const relative of Object.keys(files)) {
85
82
  const path = join(engine, relative)
86
83
  ;(existsSync(path) ? existing : missing).push(relative)
87
84
  }
88
- return { engine, locale, rootName: ROOT[locale], missing, existing }
85
+ return {
86
+ engine,
87
+ engineExists: isDir(engine),
88
+ rootName: ENGINE_ROOT,
89
+ missing,
90
+ existing,
91
+ starterComplete: missing.length === 0,
92
+ }
89
93
  }
90
94
 
91
- function validateVault(vault, requestedLocale) {
92
- const absolute = resolve(vault)
95
+ function validateRoot(rootOrEngine, { strict = false } = {}) {
96
+ const engine = resolveEngine(rootOrEngine)
97
+ const parent = resolve(engine, '..')
93
98
  const errors = []
94
- if (!existsSync(absolute) || !statSync(absolute).isDirectory()) errors.push('Vault path does not exist or is not a directory')
95
- const resolved = errors.length ? { locale: requestedLocale || 'en' } : resolveLocale(absolute, requestedLocale)
96
- if (resolved.code) return { vault: absolute, valid: false, errors: [resolved.message], ...resolved, created: [] }
97
- const result = inventory(absolute, resolved.locale)
98
- if (existsSync(result.engine) && !isDir(result.engine)) errors.push(`${result.rootName} exists but is not a directory`)
99
- return { vault: absolute, ...result, valid: errors.length === 0, errors }
99
+ if (!existsSync(parent) || !statSync(parent).isDirectory()) {
100
+ errors.push('Parent directory does not exist')
101
+ return { engine, valid: false, errors, created: [] }
102
+ }
103
+ const result = inventory(engine)
104
+ if (existsSync(engine) && !isDir(engine)) errors.push('Content Engine exists but is not a directory')
105
+ if (strict) {
106
+ if (!result.engineExists) errors.push('Content Engine/ folder does not exist')
107
+ else if (result.missing.length > 0) {
108
+ errors.push(`Starter incomplete: ${result.missing.length} file(s) missing (use --apply to add only missing files)`)
109
+ }
110
+ }
111
+ return { ...result, valid: errors.length === 0, errors, created: [] }
100
112
  }
101
113
 
102
- function applyStarter(vault, locale, noOpen) {
103
- const report = validateVault(vault, locale)
104
- if (!report.valid) return report
105
- const files = filesFor(report.locale)
106
- mkdirSync(report.engine, { recursive: true })
114
+ async function applyStarter(rootOrEngine, noOpen) {
115
+ const engine = resolveEngine(rootOrEngine)
116
+ const checked = validateRoot(engine)
117
+ if (checked.code) return { ...checked, created: [] }
118
+ mkdirSync(engine, { recursive: true })
119
+ const report = inventory(engine)
120
+ const created = []
121
+ const files = filesFor()
107
122
  for (const relative of report.missing) {
108
- const path = join(report.engine, relative)
123
+ const path = join(engine, relative)
109
124
  mkdirSync(join(path, '..'), { recursive: true })
110
125
  writeFileSync(path, files[relative], { encoding: 'utf8', flag: 'wx' })
126
+ created.push(relative)
111
127
  }
128
+ const workbench = ensureWorkbench(engine)
129
+ const python = python3Bin()
130
+ if (!python) {
131
+ return {
132
+ ...validateRoot(engine),
133
+ created,
134
+ workbench,
135
+ action: 'applied',
136
+ valid: false,
137
+ code: 'PYTHON3_REQUIRED',
138
+ errors: ['python3 is required for the workbench. Install Python 3 and retry. Starter files were written; workbench was not started.'],
139
+ }
140
+ }
141
+ const built = buildWorkbench(engine)
142
+ let started = { skipped: true, build: built }
112
143
  if (!noOpen && process.env.EASYSOCIABLE_NO_OPEN !== 'true') {
113
- const vaultName = report.vault.split('/').pop() ?? 'Vault'
114
- spawnSync('open', [`obsidian://open?vault=${encodeURIComponent(vaultName)}`], { stdio: 'ignore' })
144
+ started = await startWorkbench(engine, { noOpen: false })
115
145
  }
116
- return { ...validateVault(vault, report.locale), created: [...report.missing] }
146
+ return { ...validateRoot(engine), created, workbench, workbenchBuild: built, workbenchStart: started, action: 'applied' }
117
147
  }
118
148
 
119
149
  function printHelp() {
120
- process.stdout.write(`Usage: node setup.mjs --vault <existing-vault> --locale en|zh [--preview|--apply|--validate] [--no-open] [--json]\n\nObsidian must be installed. Ask the user to choose English or 中文 before apply. Existing files are never overwritten.\n`)
150
+ process.stdout.write(`Usage:
151
+ node setup.mjs --detect [--json]
152
+ node setup.mjs --root <parent-or-engine> [--preview|--apply|--validate] [--workbench] [--no-open] [--json]
153
+
154
+ English Content Engine/ only. Existing files are never overwritten.
155
+ `)
121
156
  }
122
157
 
123
158
  try {
@@ -126,29 +161,38 @@ try {
126
161
  printHelp()
127
162
  process.exit(0)
128
163
  }
129
- if (!hasObsidian()) {
130
- const error = { code: 'OBSIDIAN_REQUIRED', message: 'Obsidian.app is required. Install Obsidian from https://obsidian.md/download before setup.', created: false }
131
- output(error, options.json)
132
- process.exitCode = 2
164
+ if (options.detect) {
165
+ output({ action: 'detect', engines: detectEngines() }, options.json)
166
+ process.exit(0)
133
167
  }
134
- else if (!options.vault) {
135
- output({ code: 'VAULT_REQUIRED', message: 'Choose an existing Obsidian Vault with --vault.', created: false }, options.json)
168
+ const target = options.engine || options.root
169
+ if (!target) {
170
+ output({ code: 'ROOT_REQUIRED', message: 'Choose a parent directory or Content Engine path with --root.', created: false }, options.json)
136
171
  process.exitCode = 2
137
172
  }
138
- else {
139
- const report = validateVault(options.vault, options.locale)
140
- if (!report.valid) {
141
- output({ ...report, created: [] }, options.json)
173
+ else if (options.apply) {
174
+ const applied = await applyStarter(target, options.noOpen)
175
+ output(applied, options.json)
176
+ if (applied.code) process.exitCode = 2
177
+ }
178
+ else if (options.workbench) {
179
+ const engine = resolveEngine(target)
180
+ if (!isEngine(engine) && !isDir(engine)) {
181
+ output({ code: 'ENGINE_MISSING', message: 'Content Engine/ not found. Run --apply first.', created: false }, options.json)
142
182
  process.exitCode = 2
143
183
  }
144
- else if (options.apply) {
145
- const applied = applyStarter(options.vault, report.locale, options.noOpen)
146
- output({ ...applied, action: 'applied' }, options.json)
147
- }
148
184
  else {
149
- output({ ...report, action: options.validate ? 'validated' : 'preview', created: [] }, options.json)
185
+ const started = await startWorkbench(engine, { noOpen: options.noOpen })
186
+ output({ action: 'workbench', ...started, engine }, options.json)
187
+ if (!started.ok) process.exitCode = 2
150
188
  }
151
189
  }
190
+ else {
191
+ const report = validateRoot(target, { strict: options.validate })
192
+ const action = options.validate ? 'validated' : 'preview'
193
+ output({ ...report, action, created: [] }, options.json)
194
+ if (report.code || (options.validate && !report.valid)) process.exitCode = 2
195
+ }
152
196
  }
153
197
  catch (error) {
154
198
  output({ code: 'CONTENT_SETUP_FAILED', message: error instanceof Error ? error.message : String(error), created: false }, true)
@@ -6,63 +6,74 @@ import { spawnSync } from 'node:child_process'
6
6
 
7
7
  const script = new URL('./setup.mjs', import.meta.url).pathname
8
8
  const fixture = mkdtempSync(join(tmpdir(), 'easysociable-content-'))
9
- const vault = join(fixture, 'Vault')
10
- mkdirSync(vault)
11
- const env = { ...process.env, EASYSOCIABLE_SKIP_OBSIDIAN_CHECK: 'true', EASYSOCIABLE_NO_OPEN: 'true' }
9
+ const root = join(fixture, 'Workspace')
10
+ mkdirSync(root)
11
+ const env = { ...process.env, EASYSOCIABLE_NO_OPEN: 'true' }
12
12
 
13
13
  function run(args) {
14
14
  return spawnSync(process.execPath, [script, ...args, '--json'], { encoding: 'utf8', env })
15
15
  }
16
16
 
17
17
  try {
18
- const missingObsidian = spawnSync(process.execPath, [script, '--vault', vault, '--locale', 'en', '--apply', '--json'], {
19
- encoding: 'utf8',
20
- env: { ...env, EASYSOCIABLE_SKIP_OBSIDIAN_CHECK: 'false', EASYSOCIABLE_OBSIDIAN_APP: join(fixture, 'missing-Obsidian.app') },
21
- })
22
- assert.equal(missingObsidian.status, 2)
23
- assert.equal(existsSync(join(vault, 'Content Engine')), false)
24
-
25
- const missingLocale = run(['--vault', vault, '--preview'])
26
- assert.equal(missingLocale.status, 2)
27
- assert.equal(JSON.parse(missingLocale.stdout).code, 'LOCALE_REQUIRED')
18
+ const missingRoot = run(['--preview'])
19
+ assert.equal(missingRoot.status, 2)
20
+ assert.equal(JSON.parse(missingRoot.stdout).code, 'ROOT_REQUIRED')
28
21
 
29
- const preview = run(['--vault', vault, '--locale', 'en', '--preview'])
22
+ const preview = run(['--root', root, '--preview'])
30
23
  assert.equal(preview.status, 0)
31
24
  const previewReport = JSON.parse(preview.stdout)
32
25
  assert.equal(previewReport.created.length, 0)
33
26
  assert.equal(previewReport.valid, true)
34
- assert.equal(previewReport.locale, 'en')
27
+ assert.equal(previewReport.rootName, 'Content Engine')
35
28
 
36
- const apply = run(['--vault', vault, '--locale', 'en', '--apply', '--no-open'])
37
- assert.equal(apply.status, 0)
29
+ const apply = run(['--root', root, '--apply', '--no-open'])
30
+ assert.equal(apply.status, 0, apply.stdout + apply.stderr)
38
31
  const applied = JSON.parse(apply.stdout)
39
32
  assert.equal(applied.action, 'applied')
40
- assert.equal(applied.created.length > 5, true)
41
- assert.equal(readFileSync(join(vault, 'Content Engine', 'CLAUDE.md'), 'utf8').includes('local Obsidian'), true)
42
- assert.equal(JSON.parse(readFileSync(join(vault, 'Content Engine', 'locale.json'), 'utf8')).locale, 'en')
43
-
44
- const sentinel = join(vault, 'Content Engine', 'runs', 'user-note.md')
33
+ assert.equal(applied.created.length > 10, true)
34
+ const engine = join(root, 'Content Engine')
35
+ assert.equal(readFileSync(join(engine, 'CLAUDE.md'), 'utf8').includes('one platform = one Run') || readFileSync(join(engine, 'CLAUDE.md'), 'utf8').includes('One folder = one platform'), true)
36
+ const engineJson = JSON.parse(readFileSync(join(engine, 'engine.json'), 'utf8'))
37
+ assert.equal(engineJson.schemaVersion, 'content-engine.v1')
38
+ assert.equal(engineJson.root, 'Content Engine')
39
+ assert.equal(engineJson.paths.runs, 'runs')
40
+ assert.equal(existsSync(join(engine, 'runs', '_template', 'idea.md')), true)
41
+ assert.equal(existsSync(join(engine, 'runs', '_template', 'run.md')), false)
42
+ assert.equal(existsSync(join(engine, 'platforms', 'ig.md')), true)
43
+ assert.equal(existsSync(join(engine, 'discovery', '_template.md')), true)
44
+ assert.equal(existsSync(join(engine, 'discovery', '_index.md')), true)
45
+ assert.equal(readFileSync(join(engine, 'CLAUDE.md'), 'utf8').includes('§ Discovery'), true)
46
+ assert.equal(existsSync(join(engine, 'workbench', 'build.py')), true)
47
+ assert.equal(existsSync(join(engine, 'workbench', 'template.html')), true)
48
+ assert.equal(existsSync(join(engine, 'workbench', 'index.html')), true)
49
+ const sentinel = join(engine, 'runs', 'user-note.md')
45
50
  writeFileSync(sentinel, 'keep me', 'utf8')
46
- const second = run(['--vault', vault, '--apply', '--no-open'])
51
+ const customTemplate = join(engine, 'workbench', 'template.html')
52
+ const before = readFileSync(customTemplate, 'utf8')
53
+ writeFileSync(customTemplate, `${before}\n<!-- user-custom -->\n`, 'utf8')
54
+ const second = run(['--root', root, '--apply', '--no-open'])
47
55
  assert.equal(second.status, 0)
48
56
  assert.equal(readFileSync(sentinel, 'utf8'), 'keep me')
57
+ assert.equal(readFileSync(customTemplate, 'utf8').includes('user-custom'), true)
58
+ assert.equal(JSON.parse(second.stdout).created.includes('CLAUDE.md'), false)
49
59
 
50
- const mismatch = run(['--vault', vault, '--locale', 'zh', '--apply', '--no-open'])
51
- assert.equal(mismatch.status, 2)
52
- assert.equal(JSON.parse(mismatch.stdout).code, 'LOCALE_MISMATCH')
53
- assert.equal(existsSync(join(vault, '内容引擎')), false)
60
+ const detectRoot = join(fixture, 'DetectRoot')
61
+ const detectEngine = join(detectRoot, 'MyFolder', 'Content Engine')
62
+ mkdirSync(detectEngine, { recursive: true })
63
+ const detect = spawnSync(process.execPath, [script, '--detect', '--json'], {
64
+ encoding: 'utf8',
65
+ env: { ...env, EASYSOCIABLE_ENGINE_SEARCH_ROOTS: detectRoot },
66
+ })
67
+ assert.equal(detect.status, 0, detect.stdout + detect.stderr)
68
+ const detectReport = JSON.parse(detect.stdout)
69
+ assert.equal(detectReport.action, 'detect')
70
+ assert.equal(detectReport.engines.includes(detectEngine), true)
54
71
 
55
- const zhVault = join(fixture, 'ZhVault')
56
- mkdirSync(zhVault)
57
- const zhApply = run(['--vault', zhVault, '--locale', 'zh', '--apply', '--no-open'])
58
- assert.equal(zhApply.status, 0)
59
- const zhEngine = join(zhVault, '内容引擎')
60
- assert.equal(existsSync(join(vault, 'Content Engine')), true)
61
- assert.equal(readFileSync(join(zhEngine, 'CLAUDE.md'), 'utf8').includes('内容引擎'), true)
62
- assert.equal(existsSync(join(zhEngine, '成稿', '_索引.md')), true)
63
- assert.equal(existsSync(join(zhEngine, '选题', '观察清单.md')), true)
64
- assert.equal(JSON.parse(readFileSync(join(zhEngine, 'locale.json'), 'utf8')).locale, 'zh')
65
- assert.equal(existsSync(join(zhVault, 'Content Engine')), false)
72
+ const validateOk = run(['--root', root, '--validate'])
73
+ assert.equal(validateOk.status, 0)
74
+ rmSync(join(engine, 'engine.json'))
75
+ const validateMissing = run(['--root', root, '--validate'])
76
+ assert.equal(validateMissing.status, 2)
66
77
 
67
78
  console.log('Content Skill setup smoke passed.')
68
79
  }
@@ -1,53 +1,55 @@
1
- export const ROOT = { en: 'Content Engine', zh: '内容引擎' }
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
2
4
 
3
- export const STARTER = {
4
- en: {
5
- 'CLAUDE.md': `# Content Engine\n\nThis folder is the local Obsidian source of truth for EasySociable content runs.\n\n- Keep semantic copy and media references here.\n- Save each run under \`runs/<slug>/\`.\n- Save final PNGs and manifest files only under \`exports/<slug>/<platform>/\`.\n- Do not put private Template AST, Binding Maps or RenderPlans in this vault.\n- Use the unified \`easysociable\` CLI/MCP for hosted Template and Slideshow operations.\n- Pack JSON (\`content-pack.v1\`) field names stay English for the Slideshow handoff.\n`,
6
- 'accounts/_index.md': '# Accounts\n\nAdd account notes here. Do not store API keys or passwords.\n',
7
- 'exports/README.md': '# Exports\n\nFinal PNGs and manifests written by the unified render export command.\n',
8
- 'inbox/README.md': '# Inbox\n\nTemporary notes awaiting triage into topics, swipe files or runs.\n',
9
- 'inbox/_index.md': '# Inbox Index\n',
10
- 'profiles/_index.md': '# Profiles\n\nStore local profile references and presentation preferences.\n',
11
- 'published/_index.md': '# Published\n\nOptional local index of content already published elsewhere.\n',
12
- 'raw/_index.md': '# Raw\n\nSource notes and imported research.\n',
13
- 'runs/README.md': '# Runs\n\nEach run stores semantic content, platform Packs and render references.\n',
14
- 'runs/_index.md': '# Runs Index\n',
15
- 'swipe/_index.md': '# Swipe\n',
16
- 'swipe/_template.md': '# Swipe Item\n\n## Source\n\n## Pattern\n\n## Reusable insight\n',
17
- 'topics/_index.md': '# Topics\n',
18
- 'topics/WATCHLIST.md': '# Watchlist\n\nKeep links and observations here. Network tools are optional.\n',
19
- 'topics/_template-item.md': '# Topic\n\n## Thesis\n\n## Audience\n\n## Evidence\n',
20
- 'wiki/README.md': '# Wiki\n\nStable concepts and operating rules.\n',
21
- 'platforms/x.md': '# X\n\nKeep X-specific copy and constraints here.\n',
22
- 'platforms/threads.md': '# Threads\n\nKeep Threads-specific copy and constraints here.\n',
23
- 'platforms/tiktok.md': '# TikTok\n\nKeep TikTok-specific copy and constraints here.\n',
24
- },
25
- zh: {
26
- 'CLAUDE.md': `# 内容引擎\n\n此文件夹是 EasySociable 内容成稿的本地 Obsidian 真相源。\n\n- 在此保存语义文案和素材引用。\n- 每条成稿放在 \`成稿/<slug>/\`。\n- 最终 PNG 和清单只放在 \`导出/<slug>/<platform>/\`。\n- 不要把模板私有 AST、Binding Map 或 RenderPlan 放进此库。\n- 托管的模板与幻灯片操作使用统一的 \`easysociable\` CLI/MCP。\n- Pack JSON(\`content-pack.v1\`)的字段名保持英文,以便交给 Slideshow Skill。\n`,
27
- '账号/_索引.md': '# 账号\n\n在此记录账号备注。不要存放 API 密钥或密码。\n',
28
- '导出/说明.md': '# 导出\n\n由统一渲染导出命令写入的最终 PNG 和清单文件。\n',
29
- '收件箱/说明.md': '# 收件箱\n\n待整理到选题、拆解或成稿的临时笔记。\n',
30
- '收件箱/_索引.md': '# 收件箱索引\n',
31
- '档案/_索引.md': '# 档案\n\n存放本地档案引用和呈现偏好。\n',
32
- '已发布/_索引.md': '# 已发布\n\n可选:已在其他地方发布的内容本地索引。\n',
33
- '素材/_索引.md': '# 素材\n\n源笔记和导入的调研。\n',
34
- '成稿/说明.md': '# 成稿\n\n每条成稿保存语义内容、各平台 Pack 和渲染引用。\n',
35
- '成稿/_索引.md': '# 成稿索引\n',
36
- '拆解/_索引.md': '# 拆解\n',
37
- '拆解/_模板.md': '# 拆解条目\n\n## 来源\n\n## 模式\n\n## 可复用洞察\n',
38
- '选题/_索引.md': '# 选题\n',
39
- '选题/观察清单.md': '# 观察清单\n\n在此保存链接和观察。网络检索工具是可选的。\n',
40
- '选题/_模板.md': '# 选题\n\n## 论点\n\n## 受众\n\n## 证据\n',
41
- '百科/说明.md': '# 百科\n\n稳定概念和操作规则。\n',
42
- '平台/x.md': '# X\n\n在此保存 X 平台的文案和约束。\n',
43
- '平台/threads.md': '# Threads\n\n在此保存 Threads 平台的文案和约束。\n',
44
- '平台/tiktok.md': '# TikTok\n\n在此保存 TikTok 平台的文案和约束。\n',
45
- },
5
+ export const ENGINE_ROOT = 'Content Engine'
6
+ export const STARTER_DIR = fileURLToPath(new URL('../starter/', import.meta.url))
7
+ export const WORKBENCH_SRC = fileURLToPath(new URL('../workbench/', import.meta.url))
8
+ export const WORKBENCH_FILES = ['template.html', 'build.py', 'serve.py']
9
+
10
+ export const ENGINE_PATHS = {
11
+ runs: 'runs',
12
+ exports: 'exports',
13
+ topics: 'topics',
14
+ discovery: 'discovery',
15
+ swipe: 'swipe',
16
+ profiles: 'profiles',
17
+ inbox: 'inbox',
18
+ accounts: 'accounts',
19
+ raw: 'raw',
20
+ published: 'published',
21
+ wiki: 'wiki',
22
+ platforms: 'platforms',
23
+ workbench: 'workbench',
24
+ }
25
+
26
+ export function engineJson() {
27
+ return `${JSON.stringify({
28
+ schemaVersion: 'content-engine.v1',
29
+ locale: 'en',
30
+ root: ENGINE_ROOT,
31
+ paths: ENGINE_PATHS,
32
+ }, null, 2)}\n`
33
+ }
34
+
35
+ function walkFiles(dir) {
36
+ const out = []
37
+ if (!existsSync(dir)) return out
38
+ for (const name of readdirSync(dir)) {
39
+ if (name === '.DS_Store') continue
40
+ const path = join(dir, name)
41
+ if (statSync(path).isDirectory()) out.push(...walkFiles(path))
42
+ else out.push(path)
43
+ }
44
+ return out
46
45
  }
47
46
 
48
- export function filesFor(locale) {
49
- return {
50
- ...STARTER[locale],
51
- 'locale.json': `${JSON.stringify({ locale }, null, 2)}\n`,
47
+ export function filesFor() {
48
+ const files = {}
49
+ for (const path of walkFiles(STARTER_DIR)) {
50
+ const relativePath = relative(STARTER_DIR, path).split('\\').join('/')
51
+ files[relativePath] = readFileSync(path, 'utf8')
52
52
  }
53
+ files['engine.json'] = engineJson()
54
+ return files
53
55
  }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve } from 'node:path'
4
+ import { validateExportDirectory } from './export-contracts.mjs'
5
+
6
+ function parseArgs(argv) {
7
+ const options = { exportDir: '', expectedPages: undefined, json: false }
8
+ for (let i = 0; i < argv.length; i += 1) {
9
+ const arg = argv[i]
10
+ if (arg === '--export') options.exportDir = argv[++i] ?? ''
11
+ else if (arg === '--expected-pages') options.expectedPages = Number(argv[++i])
12
+ else if (arg === '--json') options.json = true
13
+ else if (arg === '--help' || arg === '-h') options.help = true
14
+ else throw new Error(`Unknown argument: ${arg}`)
15
+ }
16
+ return options
17
+ }
18
+
19
+ function output(value, json) {
20
+ process.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `${value.message ?? value}\n`)
21
+ }
22
+
23
+ try {
24
+ const options = parseArgs(process.argv.slice(2))
25
+ if (options.help) {
26
+ output({ usage: 'node validate-export.mjs --export <export-dir> [--expected-pages N] [--json]' }, false)
27
+ process.exit(0)
28
+ }
29
+ if (!options.exportDir) {
30
+ output({ code: 'EXPORT_REQUIRED', message: 'Pass --export <export-dir>.', valid: false }, options.json)
31
+ process.exitCode = 2
32
+ }
33
+ else {
34
+ const report = validateExportDirectory(resolve(options.exportDir), { expectedPages: options.expectedPages })
35
+ output({ ...report, action: 'validated', export: resolve(options.exportDir) }, options.json)
36
+ if (!report.valid) process.exitCode = 2
37
+ }
38
+ }
39
+ catch (error) {
40
+ output({ code: 'EXPORT_VALIDATE_FAILED', message: error instanceof Error ? error.message : String(error), valid: false }, true)
41
+ process.exitCode = 1
42
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs'
4
+ import { resolve } from 'node:path'
5
+ import { validateContentPack } from './contracts.mjs'
6
+
7
+ function parseArgs(argv) {
8
+ const options = { file: '', platform: '', requirePageTemplateId: false, json: false }
9
+ for (let i = 0; i < argv.length; i += 1) {
10
+ const arg = argv[i]
11
+ if (arg === '--file') options.file = argv[++i] ?? ''
12
+ else if (arg === '--platform') options.platform = argv[++i] ?? ''
13
+ else if (arg === '--require-page-template-id') options.requirePageTemplateId = true
14
+ else if (arg === '--json') options.json = true
15
+ else if (arg === '--help' || arg === '-h') options.help = true
16
+ else throw new Error(`Unknown argument: ${arg}`)
17
+ }
18
+ return options
19
+ }
20
+
21
+ function output(value, json) {
22
+ process.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `${value.message ?? value}\n`)
23
+ }
24
+
25
+ try {
26
+ const options = parseArgs(process.argv.slice(2))
27
+ if (options.help) {
28
+ output({ usage: 'node validate-pack.mjs --file <pack.json> [--platform tiktok] [--require-page-template-id] [--json]' }, false)
29
+ process.exit(0)
30
+ }
31
+ if (!options.file) {
32
+ output({ code: 'FILE_REQUIRED', message: 'Pass --file <pack.json>.', valid: false }, options.json)
33
+ process.exitCode = 2
34
+ }
35
+ else {
36
+ const pack = JSON.parse(readFileSync(resolve(options.file), 'utf8'))
37
+ const report = validateContentPack(pack, {
38
+ requirePageTemplateId: options.requirePageTemplateId,
39
+ platform: options.platform || undefined,
40
+ })
41
+ output({ ...report, action: 'validated', file: resolve(options.file) }, options.json)
42
+ if (!report.valid) process.exitCode = 2
43
+ }
44
+ }
45
+ catch (error) {
46
+ output({ code: 'PACK_VALIDATE_FAILED', message: error instanceof Error ? error.message : String(error), valid: false }, true)
47
+ process.exitCode = 1
48
+ }