@topmindspace/topmind-writing-skills 0.9.0 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [0.9.2] - 2026-10-31
2
+
3
+ - 宿主包运行时显式提供文章渲染、从源稿渲染、本地文本提取、长度评估和长度规则集评估。文章结果绑定所选稿与产物的 SHA-256,可选绑定创作快照指纹。
4
+ - 长度规则集只接受调用方传入的 JSON,先校验结构和日期再报告。本包不附带、也不启用真实平台规则。Unicode 码点上限不能代替 X 加权长度或编辑器里的最终核对。
5
+ - 八个技能的版本不变。
6
+
7
+ ## [0.9.1] - 2026-10-28
8
+
9
+ - 安装器新增 `install all`、`update` 与 `status`:整套首次安装只需一条命令,后续更新只处理已装技能,状态直接显示安装版本与包快照。
10
+ - 默认目标优先识别真实安装与宿主配置;多个候选要求明确 `--to`,避免静默写入错误目录。
11
+ - 安装记录用于识别本地改动;受管副本的改动在自动更新时会阻止覆盖,显式强制更新与旧版未受管安装会保留旧副本。软链接和异名目录不再被替换。
12
+ - 根 README 中英文对齐新的上手、升级与本地安装流程。
13
+
1
14
  ## [0.9.0] - 2026-10-27
2
15
 
3
16
  - 扩展 8 个技能的创作方向、完整示例和适用条件,保留事实、排版、保真与工具协议,清理统一审美门槛和旧规则冲突。
package/README.en.md CHANGED
@@ -14,10 +14,12 @@ English | [中文](./README.md)
14
14
  </p>
15
15
 
16
16
  ```bash
17
- npx @topmindspace/topmind-writing-skills install topmind-viral-posts # install one
18
- npx @topmindspace/topmind-writing-skills list # see all
17
+ npx @topmindspace/topmind-writing-skills install all # first-time setup
18
+ npx @topmindspace/topmind-writing-skills@latest update # later, update installed skills
19
19
  ```
20
20
 
21
+ Then tell your AI assistant "revise this", "write a quick brief", or "make a cover for this article". To install just one skill, use `install topmind-revise` or another ID from `list`. Desktop and Obsidian already include a reviewed writing snapshot; update it through each app's About & Updates screen rather than these npx commands.
22
+
21
23
  Hosts start with the active note and allow explicit source changes. Drafts, a small read-only memory window and the selected skill are loaded in layers; other resources are retrieved as needed. Compaction does not reset completed work or budgets.
22
24
 
23
25
  ## Desktop and Obsidian integration
@@ -28,6 +30,8 @@ Open a Markdown document and choose **Writing & delivery** from the article tool
28
30
 
29
31
  Built-in HTML/text typography requires no Python. Preview and copy use the same output; changed sources or images require regeneration. Diagnostics identify missing images, diagram source, extended containers and table paste limitations. AI images, PNG rendering and publishing require their respective tools. Copying does not publish; wording scores do not decide editorial quality.
30
32
 
33
+ The host pack also renders from source bytes, extracts local text from clipboard HTML, and evaluates a length rule set. The length entry accepts only caller-supplied JSON. This package does not ship or enable production platform rules. Fields, dates and reports are in [the length rule-set contract](docs/length-ruleset-v1.md). A Unicode code-point ceiling is not X weighted length and is not the target editor's final check. JavaScript and Python renderer differences are in [the capability matrix](docs/renderer-capability-matrix.md).
34
+
31
35
  About & updates tracks the application, core skills, independent writing pack and integrations separately. Writing resources can update independently; compiled renderer code follows the host release. Managed packs are isolated, local modifications block replacement and failed installs preserve the previous version. Supported Desktop installs can download an update and explicitly restart; manual downloads remain available when signing or the installation channel prevents automatic updates.
32
36
 
33
37
  ## More choices, concrete references
@@ -240,15 +244,19 @@ Every skill drafts only; you publish.
240
244
  **npm = pinned snapshot; GitHub = latest.**
241
245
 
242
246
  ```bash
247
+ npx @topmindspace/topmind-writing-skills install all --to ./.claude/skills
248
+ npx @topmindspace/topmind-writing-skills status --to ./.claude/skills
249
+ npx @topmindspace/topmind-writing-skills@latest update --to ./.claude/skills
243
250
  npx @topmindspace/topmind-writing-skills install topmind-briefs --to ./.claude/skills
244
- npx @topmindspace/topmind-writing-skills@latest install topmind-briefs # upgrade in place
245
- npx @topmindspace/topmind-writing-skills install topmind-briefs --force # reinstall / downgrade
251
+ npx @topmindspace/topmind-writing-skills install topmind-briefs --force --to ./.claude/skills
246
252
  npx @topmindspace/topmind-writing-skills uninstall topmind-briefs --to ./.claude/skills
247
- npx github:topmindspace/topmind-writing-skills install topmind-briefs # latest from GitHub
253
+ npx github:topmindspace/topmind-writing-skills update --to ./.claude/skills
248
254
  ```
249
255
 
250
- - Probes `./.agents` → `./.claude` → `./.cursor` → `./.codex` → `./.mimocode`, then user-level directories; the summary prints the target.
251
- - Older installs are staged before replacement; failures retain the previous copy. Equal versions report up to date; reinstalling the same version or downgrading needs `--force`. Back up local modifications first.
256
+ - `install all` installs the full set once; `update` changes only already installed skills, and `status` compares the package snapshot with a destination. All accept `--to`.
257
+ - Without `--to`, the CLI selects a single existing TopMind installation, skills directory, or host configuration. If several match, it lists them and asks for `--to`. With no host configured, it uses `./.agents/skills` in the current project. Output always names the destination.
258
+ - Replacements are staged first; failures keep the old copy. Local edits to a managed skill stop automatic updates until `--force` is explicit, which retains the previous copy. Upgrading an older untracked installation also retains a backup. Symlinks and unrelated directories are never replaced.
259
+ - Equal versions are left alone; downgrading a newer installed version needs `--force`. Reopen the AI session after updating so the host discovers new skills.
252
260
  - Skill ids are not npm packages; don't `npm install topmind-briefs`.
253
261
 
254
262
  ### Upgrade from a local checkout
@@ -256,12 +264,11 @@ npx github:topmindspace/topmind-writing-skills install topmind-briefs # late
256
264
  Install local, unpublished changes from the checkout; `@latest` contains only the published npm snapshot. Verify the target first. If tools share skills through symlinks, update the resolved shared directory and preserve those links. For a standalone Codex installation, run from the repository root:
257
265
 
258
266
  ```bash
259
- for skill in $(node scripts/discover_skills.js); do
260
- node bin/topmind-writing-skills.js install "$skill" --to "$HOME/.codex/skills" || break
261
- done
267
+ node bin/topmind-writing-skills.js status --to "$HOME/.codex/skills"
268
+ node bin/topmind-writing-skills.js update --to "$HOME/.codex/skills"
262
269
  ```
263
270
 
264
- Replace `--to` with the verified shared destination when appropriate and check every result. Reopen the session to refresh skill discovery; unpublished changes with the same version require an explicit `--force` reinstall.
271
+ Use `install all` for the first install. Replace `--to` with the verified shared destination when appropriate. Unpublished changes with the same skill version require `install <id> --force --to <actual-dir>`. The CLI copies each skill's self-contained resources and never writes to a destination's root `shared/` directory.
265
272
 
266
273
  <details>
267
274
  <summary>Repository layout and releases</summary>
package/README.md CHANGED
@@ -14,10 +14,12 @@
14
14
  </p>
15
15
 
16
16
  ```bash
17
- npx @topmindspace/topmind-writing-skills install topmind-viral-posts # 装一个
18
- npx @topmindspace/topmind-writing-skills list # 看全部
17
+ npx @topmindspace/topmind-writing-skills install all # 首次安装整套
18
+ npx @topmindspace/topmind-writing-skills@latest update # 以后只更新已装技能
19
19
  ```
20
20
 
21
+ 装好后直接对 AI 说「改这篇」「写条快讯」「给这篇做封面」。只想装一个,用 `install topmind-revise` 等技能 ID;`list` 查看全部。Desktop 和 Obsidian 已内置经过验证的写作快照,应用内更新从各自“关于与更新”进入,不需要再运行上面的 npx 命令。
22
+
21
23
  宿主默认绑定活跃笔记,允许显式换稿。当前稿件、少量全局记忆和所选技能分层加载;其余资源按需读取,压缩不重置已完成操作和预算。
22
24
 
23
25
  ## 在 Desktop 与 Obsidian 中使用
@@ -28,6 +30,8 @@ npx @topmindspace/topmind-writing-skills list # 看全
28
30
 
29
31
  内置 HTML/文本排版无需 Python,预览与复制同源;源稿或图片改变后需要重新生成。扩展容器、图表源码、缺图和平台表格粘贴限制会显示诊断。封面生图、PNG 和外部发布依赖实际工具,复制不等于已发布。审校不使用“AI 味分数”作为定稿门槛。
30
32
 
33
+ 宿主包运行时另外提供从源稿生成文章结果、从剪贴板 HTML 提取本地文本,以及长度规则集评估。长度入口只接受调用方传入的 JSON,本包不附带、也不启用真实平台规则。规则字段、日期和报告见 [长度规则集运行契约](docs/length-ruleset-v1.md)。Unicode 码点上限不能代替 X 加权长度或目标编辑器里的最终核对。JavaScript 与 Python 渲染器的差异见 [能力矩阵](docs/renderer-capability-matrix.md)。
34
+
31
35
  “关于与更新”分别展示 Desktop、核心技能、独立写作包和集成组件。写作资源可独立更新;编译进宿主的排版代码随宿主升级。受管包按身份隔离,覆盖本地定制会被阻止,失败保留旧版。Desktop 支持标准更新器下载后显式重启;签名、平台或渠道不支持时保留手动下载。
32
36
 
33
37
  ## 加选项,让经验帮助创作
@@ -258,15 +262,19 @@ python3 scripts/de_ai_lint.py 文章.md --genre long # 长文
258
262
  **npm = 钉版本快照;GitHub = 跟仓库最新。**
259
263
 
260
264
  ```bash
265
+ npx @topmindspace/topmind-writing-skills install all --to ./.claude/skills
266
+ npx @topmindspace/topmind-writing-skills status --to ./.claude/skills
267
+ npx @topmindspace/topmind-writing-skills@latest update --to ./.claude/skills
261
268
  npx @topmindspace/topmind-writing-skills install topmind-briefs --to ./.claude/skills
262
- npx @topmindspace/topmind-writing-skills@latest install topmind-briefs # 升级:暂存完成再替换,失败保留旧版
263
- npx @topmindspace/topmind-writing-skills install topmind-briefs --force # 同版本重装 / 降级
269
+ npx @topmindspace/topmind-writing-skills install topmind-briefs --force --to ./.claude/skills
264
270
  npx @topmindspace/topmind-writing-skills uninstall topmind-briefs --to ./.claude/skills
265
- npx github:topmindspace/topmind-writing-skills install topmind-briefs # 跟仓库最新
271
+ npx github:topmindspace/topmind-writing-skills update --to ./.claude/skills
266
272
  ```
267
273
 
268
- - 默认探测 `./.agents` → `./.claude` → `./.cursor` → `./.codex` → `./.mimocode`,再到用户级目录;安装摘要会打印实际目标目录。
269
- - 更旧的安装先暂存再替换,失败保留旧版;版本相同提示已是最新;同版本重装或降级用 `--force`。有本地修改先备份。
274
+ - `install all` 首次安装整套;`update` 默认只更新已装的技能,不会擅自加装新技能;`status` 显示当前包快照与安装目录的差异。三者都支持 `--to` 指定实际目录。
275
+ - 不写 `--to` 时先找唯一的 TopMind 安装,再找唯一的技能目录或宿主配置;若同时存在多个候选,会列出目录并要求明确选择。没有宿主配置时使用当前项目的 `./.agents/skills`。结果会打印实际目录。
276
+ - 更新先复制到临时目录,完整后才替换;失败保留旧版。受管安装发现本地改动会停止,需明确 `--force`;强制替换时保留旧副本。升级早期未带安装记录的副本也会自动留备份。软链接或非同名技能目录不会被覆盖。
277
+ - 同版本不重复复制;已装版本比包新时拒绝降级,除非显式 `--force`。升级后重新打开 AI 会话,让宿主重新发现技能。
270
278
  - 技能 id 不是独立 npm 包,不要 `npm install topmind-briefs`。
271
279
 
272
280
  ### 从本地仓库升级
@@ -274,11 +282,12 @@ npx github:topmindspace/topmind-writing-skills install topmind-briefs # 跟
274
282
  本地未发布改动要从当前仓库安装,`@latest` 只包含 npm 已发布版本。先核实目标目录;已有跨工具软链接时更新其共享实际目录,保留链接关系。Codex 独立安装可在仓库根目录运行:
275
283
 
276
284
  ```bash
277
- for skill in $(node scripts/discover_skills.js); do
278
- node bin/topmind-writing-skills.js install "$skill" --to "$HOME/.codex/skills" || break
279
- done
285
+ node bin/topmind-writing-skills.js status --to "$HOME/.codex/skills"
286
+ node bin/topmind-writing-skills.js update --to "$HOME/.codex/skills"
280
287
  ```
281
288
 
289
+ 首次安装改用 `install all`;修改过技能内容而未改版本号时,用 `install <id> --force --to <实际目录>` 明确重装。CLI 只复制单个技能目录内的独立资源,不写入目标根目录的 `shared/`。
290
+
282
291
  若 Codex 技能链接到其他共享目录,将 `--to` 换为核实后的实际目录;检查每项安装输出。安装了新技能或更新入口后,重开会话让宿主重新发现;同版本未发布内容需要明确加 `--force` 才会重装。
283
292
 
284
293
  <details>
@@ -17,11 +17,13 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+ const crypto = require('crypto');
20
21
 
21
22
  const ROOT = path.resolve(__dirname, '..');
22
23
  // Skills live at repo root (one directory per skill with SKILL.md).
23
24
  const INFRA_DIRS = new Set(['bin', 'docs', 'scripts', 'shared', 'evals', 'node_modules', 'dist', 'release-assets']);
24
25
  const SKILL_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
26
+ const INSTALL_RECORD = '.topmind-install.json';
25
27
 
26
28
  function die(msg, code = 1) {
27
29
  console.error(msg);
@@ -113,7 +115,7 @@ function installerVersion() {
113
115
 
114
116
  function resolveDefaultTarget() {
115
117
  const home = process.env.HOME || process.env.USERPROFILE || '';
116
- const candidates = [
118
+ const candidates = [...new Set([
117
119
  path.join(process.cwd(), '.agents', 'skills'),
118
120
  path.join(process.cwd(), '.claude', 'skills'),
119
121
  path.join(process.cwd(), '.cursor', 'skills'),
@@ -123,25 +125,89 @@ function resolveDefaultTarget() {
123
125
  path.join(home, '.agents', 'skills'),
124
126
  path.join(home, '.cursor', 'skills'),
125
127
  path.join(home, '.codex', 'skills'),
126
- ];
127
- for (const dir of candidates) {
128
- if (dir && fs.existsSync(path.dirname(dir))) {
129
- return dir;
130
- }
128
+ ])];
129
+ const choose = (paths) => {
130
+ if (paths.length === 1) return paths[0];
131
+ if (paths.length > 1) die(`More than one skill directory matches:\n ${paths.join('\n ')}\nChoose one with --to <dir>.`);
132
+ return null;
133
+ };
134
+ // Prefer the user's existing TopMind installation, then an existing skills
135
+ // directory, then a single host configuration. A mere cwd is not a signal.
136
+ const ids = listSkillIds();
137
+ const withTopmind = candidates.filter((dir) => ids.some((id) => fs.existsSync(path.join(dir, id, 'SKILL.md'))));
138
+ const withSkills = candidates.filter((dir) => fs.existsSync(dir));
139
+ const withHost = candidates.filter((dir) => fs.existsSync(path.dirname(dir)) && path.dirname(dir) !== process.cwd() && path.dirname(dir) !== home);
140
+ for (const group of [withTopmind, withSkills, withHost]) {
141
+ const selected = choose(group);
142
+ if (selected) return selected;
131
143
  }
144
+ // No host exists yet: use the cross-agent project convention explicitly.
132
145
  return path.join(process.cwd(), '.agents', 'skills');
133
146
  }
134
147
 
148
+ function readSkillName(skillPath) {
149
+ try {
150
+ const text = fs.readFileSync(path.join(skillPath, 'SKILL.md'), 'utf8');
151
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
152
+ return (fm && fm[1].match(/^name:\s*([a-z0-9-]+)\s*$/m) || [])[1] || '';
153
+ } catch { return ''; }
154
+ }
155
+
156
+ function skipGeneratedEntry(entry) {
157
+ return (entry.isDirectory() && ['node_modules', 'dist', '__pycache__'].includes(entry.name)) ||
158
+ (entry.isFile() && entry.name.endsWith('.pyc'));
159
+ }
160
+
161
+ function skillDigest(skillPath) {
162
+ const hash = crypto.createHash('sha256');
163
+ const walk = (dir, relative = '') => {
164
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
165
+ if (!relative && entry.name === INSTALL_RECORD) continue;
166
+ if (skipGeneratedEntry(entry)) continue;
167
+ const rel = path.join(relative, entry.name);
168
+ const absolute = path.join(dir, entry.name);
169
+ if (entry.isDirectory()) {
170
+ walk(absolute, rel);
171
+ } else {
172
+ hash.update(rel.replace(/\\/g, '/') + '\0');
173
+ if (entry.isFile()) hash.update(fs.readFileSync(absolute));
174
+ else hash.update(`non-regular:${entry.name}`);
175
+ hash.update('\0');
176
+ }
177
+ }
178
+ };
179
+ walk(skillPath);
180
+ return hash.digest('hex');
181
+ }
182
+
183
+ function installedState(skillId, targetRoot) {
184
+ const dest = path.join(targetRoot, skillId);
185
+ let stat;
186
+ try { stat = fs.lstatSync(dest); } catch (error) {
187
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return { kind: 'missing', version: '' };
188
+ throw error;
189
+ }
190
+ if (!stat.isDirectory() || readSkillName(dest) !== skillId) return { kind: 'conflict', version: '' };
191
+ const version = readSkillVersion(dest);
192
+ const recordPath = path.join(dest, INSTALL_RECORD);
193
+ if (!fs.existsSync(recordPath)) return { kind: 'legacy', version };
194
+ try {
195
+ const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
196
+ if (record.id === skillId && record.digest === skillDigest(dest)) return { kind: 'managed', version };
197
+ } catch { /* Treat unreadable records as local changes. */ }
198
+ return { kind: 'modified', version };
199
+ }
200
+
135
201
  function copyDir(src, dest) {
136
202
  fs.mkdirSync(dest, { recursive: true });
137
203
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
138
204
  const s = path.join(src, entry.name);
139
205
  const d = path.join(dest, entry.name);
140
206
  if (entry.isDirectory()) {
141
- if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '__pycache__') continue;
207
+ if (skipGeneratedEntry(entry)) continue;
142
208
  copyDir(s, d);
143
209
  } else if (entry.isFile()) {
144
- if (entry.name.endsWith('.pyc')) continue;
210
+ if (skipGeneratedEntry(entry)) continue;
145
211
  fs.copyFileSync(s, d);
146
212
  } else {
147
213
  console.warn(`warning: skipping non-regular file: ${s}`);
@@ -207,7 +273,7 @@ function compareVersions(a, b) {
207
273
  return 0;
208
274
  }
209
275
 
210
- function install(skillId, targetRoot, { force = false, defaultTarget = false } = {}) {
276
+ function planInstall(skillId, targetRoot, force) {
211
277
  assertSkillId(skillId);
212
278
  const src = skillDir(skillId);
213
279
  if (!fs.existsSync(path.join(src, 'SKILL.md'))) {
@@ -215,6 +281,26 @@ function install(skillId, targetRoot, { force = false, defaultTarget = false } =
215
281
  }
216
282
  const dest = path.join(targetRoot, skillId);
217
283
  assertDestOutsideSource(src, dest);
284
+ const state = installedState(skillId, targetRoot);
285
+ if (state.kind === 'conflict') die(`Refusing to replace ${dest}: it is not a ${skillId} skill directory.`);
286
+ if (state.kind === 'modified' && !force) {
287
+ die(`Local changes found in ${dest}. Keep them or pass --force to replace; the previous copy will be retained.`);
288
+ }
289
+ const packageVer = readSkillVersion(src) || 'unknown';
290
+ const cmp = compareVersions(packageVer, state.version);
291
+ if (state.kind !== 'missing' && !force && cmp < 0) {
292
+ die(`Installed ${skillId} ${state.version} is newer than packaged ${packageVer}; not downgrading. Pass --force (or -f) to downgrade.`);
293
+ }
294
+ return { src, dest, state, packageVer, cmp };
295
+ }
296
+
297
+ function install(skillId, targetRoot, { force = false, defaultTarget = false } = {}) {
298
+ const { src, dest, state, cmp } = planInstall(skillId, targetRoot, force);
299
+ if (state.kind !== 'missing' && !force && cmp === 0) {
300
+ console.log(`Already up to date: ${skillId} ${state.version} (use --force to reinstall)`);
301
+ console.log(` at: ${dest}`);
302
+ return;
303
+ }
218
304
  try {
219
305
  fs.mkdirSync(targetRoot, { recursive: true });
220
306
  } catch (e) {
@@ -224,37 +310,28 @@ function install(skillId, targetRoot, { force = false, defaultTarget = false } =
224
310
  'Choose a --to directory that can be created (not a file, not unwritable).'
225
311
  );
226
312
  }
227
- let upgradedFrom = null;
228
- if (fs.existsSync(dest)) {
229
- // 已装过:默认升级。打包版本更新 → 直接覆盖(不备份);相同 → 已是最新;已装版本更新 → 需 --force。
230
- const installedVer = readSkillVersion(dest);
231
- const packageVer = readSkillVersion(src) || 'unknown';
232
- const cmp = compareVersions(packageVer, installedVer);
233
- if (!force && cmp === 0) {
234
- console.log(`Already up to date: ${skillId} ${installedVer} (use --force to reinstall)`);
235
- console.log(` at: ${dest}`);
236
- return;
237
- }
238
- if (!force && cmp < 0) {
239
- die(
240
- `Installed ${skillId} ${installedVer} is newer than packaged ${packageVer}; not downgrading. ` +
241
- 'Pass --force (or -f) to downgrade.'
242
- );
243
- }
244
- upgradedFrom = cmp > 0 ? installedVer || 'unknown' : null;
245
- }
313
+ const upgradedFrom = state.kind !== 'missing' && cmp > 0 ? state.version || 'unknown' : null;
314
+ const retainPrevious = state.kind === 'legacy' || state.kind === 'modified';
246
315
  const stage = fs.mkdtempSync(path.join(targetRoot, `.${skillId}-stage-`));
247
316
  let previous = null;
248
317
  try {
249
318
  copyDir(src, stage);
250
- if (fs.existsSync(dest)) {
319
+ fs.writeFileSync(path.join(stage, INSTALL_RECORD), JSON.stringify({
320
+ id: skillId,
321
+ version: readSkillVersion(src),
322
+ digest: skillDigest(stage),
323
+ }, null, 2) + '\n');
324
+ if (state.kind !== 'missing') {
251
325
  previous = fs.mkdtempSync(path.join(targetRoot, `.${skillId}-previous-`));
252
326
  fs.rmdirSync(previous);
253
327
  fs.renameSync(dest, previous);
254
328
  }
255
329
  try { fs.renameSync(stage, dest); }
256
330
  catch (error) { if (previous) { fs.renameSync(previous, dest); previous = null; } throw error; }
257
- if (previous) { try { fs.rmSync(previous, { recursive: true, force: true }); } catch { console.warn(`Previous copy retained at: ${previous}`); } }
331
+ if (previous && !retainPrevious) {
332
+ try { fs.rmSync(previous, { recursive: true, force: true }); }
333
+ catch { console.warn(`Previous copy retained at: ${previous}`); }
334
+ }
258
335
  } catch (error) {
259
336
  fs.rmSync(stage, { recursive: true, force: true });
260
337
  die(`Install failed; the previous installed skill was retained. ${error.message}`);
@@ -266,9 +343,25 @@ function install(skillId, targetRoot, { force = false, defaultTarget = false } =
266
343
  else console.log(`Installed ${skillId}`);
267
344
  console.log(` to: ${dest}${toNote}`);
268
345
  console.log(` version: skill ${skillVer} (installer ${installerVersion()})`);
346
+ if (previous && retainPrevious) console.log(` backup: ${previous} (previous untracked or locally changed copy)`);
269
347
  console.log(` next: ${nextStep(skillId, dest)}`);
270
348
  }
271
349
 
350
+ function status(targetRoot) {
351
+ console.log(`Skill directory: ${targetRoot}`);
352
+ console.log('Packaged version | Installed version | State | Skill');
353
+ for (const id of listSkillIds()) {
354
+ const packaged = readSkillVersion(skillDir(id)) || 'unknown';
355
+ const state = installedState(id, targetRoot);
356
+ let label = state.kind;
357
+ if (state.kind === 'managed' || state.kind === 'legacy') {
358
+ const cmp = compareVersions(packaged, state.version);
359
+ label = cmp > 0 ? 'update available' : cmp < 0 ? 'installed newer' : state.kind === 'legacy' ? 'untracked' : 'current';
360
+ }
361
+ console.log(`${packaged} | ${state.version || '—'} | ${label} | ${id}`);
362
+ }
363
+ }
364
+
272
365
  function nextStep(skillId, dest) {
273
366
  return 'restart your agent so it picks up the skill';
274
367
  }
@@ -319,22 +412,25 @@ function usageText() {
319
412
 
320
413
  Usage:
321
414
  topmind-writing-skills list|ls
322
- topmind-writing-skills install <skill-id> [--to|-t <dir>] [--force|-f]
415
+ topmind-writing-skills status [--to|-t <dir>]
416
+ topmind-writing-skills install <skill-id|all> [--to|-t <dir>] [--force|-f]
417
+ topmind-writing-skills update [skill-id|all] [--to|-t <dir>] [--force|-f]
323
418
  topmind-writing-skills uninstall <skill-id> [--to|-t <dir>]
324
419
  topmind-writing-skills help
325
420
 
326
421
  Behavior:
327
- install copies <skill-id>/ into <dir>/<skill-id>/. If the skill is already
328
- installed: a newer packaged version upgrades it in place (no backup,
329
- prints "Upgraded <id> <old> -> <new>"); the same version prints
330
- "Already up to date" (--force reinstalls); an installed version newer
331
- than the package is refused unless --force. A missing/unparseable
332
- installed version counts as an upgrade. Prints where/what-version/
333
- next-steps when done.
422
+ status compares the local package with skills installed in <dir>.
423
+ install installs one skill or all eight; newer packaged versions upgrade
424
+ existing copies. Equal versions are left alone. A newer installed
425
+ version requires --force. Locally changed managed copies are kept
426
+ unless --force is explicit. Untracked older copies are backed up
427
+ during upgrade. The replacement is staged before switching.
428
+ update upgrades only installed skills (all by default); does not add new ones.
334
429
  uninstall removes <dir>/<skill-id>/ (refuses: not installed, not a skill
335
430
  directory, or the package's own skill source).
336
- No --to: the target directory is auto-detected (probe order below) and
337
- printed explicitly in the install summary.
431
+ No --to: choose a single existing TopMind installation, skills directory or
432
+ host configuration. Ambiguous matches require --to. With no host configured,
433
+ use ./.agents/skills and print the chosen location.
338
434
 
339
435
  Target probe order (no --to): ./.agents/skills, ./.claude/skills,
340
436
  ./.cursor/skills, ./.codex/skills, ./.mimocode/skills, then
@@ -342,6 +438,9 @@ Target probe order (no --to): ./.agents/skills, ./.claude/skills,
342
438
 
343
439
  Examples (npm recommended when published):
344
440
  npx @topmindspace/topmind-writing-skills list
441
+ npx @topmindspace/topmind-writing-skills status
442
+ npx @topmindspace/topmind-writing-skills install all
443
+ npx @topmindspace/topmind-writing-skills@latest update
345
444
  npx @topmindspace/topmind-writing-skills install topmind-briefs
346
445
  npx @topmindspace/topmind-writing-skills install topmind-briefs --to ./.agents/skills
347
446
  npx @topmindspace/topmind-writing-skills install topmind-briefs --force
@@ -358,6 +457,22 @@ function usage() {
358
457
  console.log(usageText());
359
458
  }
360
459
 
460
+ function parseOptions(args, start, { allowForce = false } = {}) {
461
+ let to = null;
462
+ let force = false;
463
+ for (let i = start; i < args.length; i++) {
464
+ if (args[i] === '--to' || args[i] === '-t') {
465
+ to = args[++i];
466
+ if (!to || to.startsWith('-')) die('Option --to requires a directory path.');
467
+ } else if (allowForce && (args[i] === '--force' || args[i] === '-f')) {
468
+ force = true;
469
+ } else {
470
+ die(`Unknown option: ${args[i]}\n\n` + usageText());
471
+ }
472
+ }
473
+ return { targetRoot: to ? path.resolve(to) : resolveDefaultTarget(), force, defaultTarget: !to };
474
+ }
475
+
361
476
  function main(argv) {
362
477
  const args = argv.slice(2);
363
478
  const cmd = args[0];
@@ -381,25 +496,32 @@ function main(argv) {
381
496
  return;
382
497
  }
383
498
 
384
- if (cmd === 'install') {
385
- const skillId = args[1];
386
- if (!skillId || skillId.startsWith('-')) {
499
+ if (cmd === 'status') {
500
+ const { targetRoot } = parseOptions(args, 1);
501
+ status(targetRoot);
502
+ return;
503
+ }
504
+
505
+ if (cmd === 'install' || cmd === 'update') {
506
+ const requested = cmd === 'update' && (!args[1] || args[1].startsWith('-')) ? 'all' : args[1];
507
+ if (!requested || requested.startsWith('-')) {
387
508
  die('Missing skill id.\n\n' + usageText());
388
509
  }
389
- let to = null;
390
- let force = false;
391
- for (let i = 2; i < args.length; i++) {
392
- if (args[i] === '--to' || args[i] === '-t') {
393
- to = args[++i];
394
- if (!to) die('Option --to requires a directory path.');
395
- } else if (args[i] === '--force' || args[i] === '-f') {
396
- force = true;
397
- } else {
398
- die(`Unknown option: ${args[i]}\n\n` + usageText());
399
- }
510
+ const optionStart = cmd === 'update' && requested === 'all' && args[1] !== 'all' ? 1 : 2;
511
+ const options = parseOptions(args, optionStart, { allowForce: true });
512
+ const ids = requested === 'all' ? listSkillIds() : [requested];
513
+ const selected = cmd === 'update'
514
+ ? ids.filter(id => installedState(id, options.targetRoot).kind !== 'missing')
515
+ : ids;
516
+ if (!selected.length) {
517
+ console.log(`No TopMind writing skills installed in ${options.targetRoot}. Run install all to get started.`);
518
+ return;
400
519
  }
401
- const targetRoot = to ? path.resolve(to) : resolveDefaultTarget();
402
- install(skillId, targetRoot, { force, defaultTarget: !to });
520
+ // Check every target before the first write; a conflict should not leave
521
+ // an avoidably half-updated set.
522
+ for (const id of selected) planInstall(id, options.targetRoot, options.force);
523
+ for (const id of selected) install(id, options.targetRoot, options);
524
+ if (selected.length > 1) console.log(`${cmd === 'install' ? 'Installed' : 'Updated'} ${selected.length} writing skills in ${options.targetRoot}.`);
403
525
  return;
404
526
  }
405
527
 
package/lib/host-pack.cjs CHANGED
@@ -28,7 +28,17 @@ function buildHostPack(root) {
28
28
  if (!/^---\r?\n/u.test(raw) || !/^name:\s*/mu.test(raw)) throw new Error(`Invalid skill: ${id}`);
29
29
  return { id, name: id, description: meta.description || id, version: meta.version, packId: 'topmind-writing-skills' };
30
30
  });
31
- const manifest = { schema_version: 1, name: 'topmind-writing-skills', version: pkg.version, license: pkg.license, repository: pkg.repository, entry_files: ids.map(id => `${id}/SKILL.md`), files, runtime: { api: 1, entry: 'runtime/article-renderer.mjs', update: 'host-release' } };
31
+ const capabilities = [
32
+ { id: 'article.render', entry: 'runtime/article-renderer.mjs', export: 'prepareArticleResult' },
33
+ { id: 'article.renderFromSource', entry: 'runtime/article-renderer.mjs', export: 'prepareArticleResultFromSource' },
34
+ { id: 'article.localTextDocument', entry: 'runtime/article-renderer.mjs', export: 'articleLocalTextDocument' },
35
+ { id: 'length.evaluate', entry: 'runtime/length-rules.mjs', export: 'evaluateLengthRules' },
36
+ { id: 'length.ruleset.evaluate', entry: 'runtime/length-ruleset.mjs', export: 'evaluateLengthRuleSet' },
37
+ ];
38
+ for (const capability of capabilities) {
39
+ if (!Object.hasOwn(files, capability.entry)) throw new Error(`Missing runtime capability entry: ${capability.entry}`);
40
+ }
41
+ const manifest = { schema_version: 1, name: 'topmind-writing-skills', version: pkg.version, license: pkg.license, repository: pkg.repository, entry_files: ids.map(id => `${id}/SKILL.md`), files, runtime: { api: 1, entry: 'runtime/article-renderer.mjs', capabilities, update: 'host-release' } };
32
42
  const digest = hash(JSON.stringify(manifest));
33
43
  return { manifest: { ...manifest, digest }, snapshot: { id: manifest.name, version: pkg.version, source: 'https://github.com/topmindspace/topmind-writing-skills', license: pkg.license, digest, skills, resources } };
34
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topmindspace/topmind-writing-skills",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "TopMindSpace 写作技能合集 — 通过 npm / npx 安装技能(公众号长文、X 长文、干货短文、X 引流短帖、改稿与质检、封面图、长图海报)。只出稿与发布包,不代发。",
5
5
  "license": "MIT",
6
6
  "author": "TopMindspace",
@@ -76,7 +76,7 @@
76
76
  "check:pack": "node scripts/check_npm_pack.js",
77
77
  "validate:spec": "bash -c 'for s in $(node scripts/discover_skills.js); do ${AGENTSKILLS:-agentskills} validate \"$s\" || exit 1; done'",
78
78
  "prepack": "node scripts/sync_npm_files.js --check && node scripts/sync_shared.js --check && python3 shared/scripts/knowledge.py validate",
79
- "install:skills": "node -e \"const {discoverSkills}=require('./scripts/discover_skills'); const {spawnSync}=require('child_process'); for (const id of discoverSkills()) { const r=spawnSync(process.execPath,['bin/topmind-writing-skills.js','install',id],{stdio:'inherit'}); if(r.status) process.exit(r.status||1); }\"",
79
+ "install:skills": "node bin/topmind-writing-skills.js install all",
80
80
  "check": "node scripts/run_skill_gates.js check",
81
81
  "privacy": "python3 scripts/ci_privacy_scan.py",
82
82
  "audit": "node scripts/run_skill_gates.js audit",
@@ -1,9 +1,30 @@
1
1
  export type WritingPlatform = 'wechat' | 'x' | 'text';
2
2
  export type WritingTheme = 'minimal-ink' | 'tech-blue' | 'newsprint' | 'graphite';
3
3
  export const WRITING_THEMES: Record<WritingTheme, { ink: string; accent: string; paper: string; muted: string }>;
4
+ export const ARTICLE_RENDERER_PROVIDER: Readonly<{ id: string; version: string }>;
4
5
  export const WRITING_ACTIONS: ReadonlyArray<{ id: string; zh: string; en: string; prompt: string; promptEn: string }>;
5
6
  export function stripFrontmatter(value: string): string;
6
7
  export interface ArticleDelivery { platform: WritingPlatform; theme: WritingTheme; html: string; body: string; text: string; checklist: string; images: Array<{ source: string; alt: string; status: string }>; diagnostics: Array<{ code: string; source?: string }> }
7
8
  export function renderArticle(input: { markdown: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string; images?: Record<string, string> }): ArticleDelivery;
8
9
  export function resolveArticleImage(sourcePath: string, reference: string): string;
9
10
  export function prepareArticle(input: { sourcePath: string; markdown: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string }, readImage: (relative: string) => Promise<string>): Promise<ArticleDelivery>;
11
+ export interface ArticleResult {
12
+ schemaVersion: 1;
13
+ capabilityId: 'article.render';
14
+ provider: Readonly<{ id: string; version: string }>;
15
+ status: 'ok' | 'warning' | 'error';
16
+ input: { fingerprint: string | null; target?: { platform: WritingPlatform; theme: WritingTheme };
17
+ source?: { relativePath: string; sha256: string };
18
+ imageBytes?: Array<{ relativePath: string; sha256: string }>;
19
+ creationSnapshotFingerprint?: string; localTextTitle?: string | null;
20
+ renderParameters?: { theme: WritingTheme; title: string } };
21
+ artifacts: Array<{ role: 'preview' | 'clipboard-html' | 'source-markdown' | 'image-checklist' | 'local-clipboard-text'; format: string; content: string; sha256: string }>;
22
+ images: ArticleDelivery['images'];
23
+ diagnostics: Array<{ code: string; severity: 'warning' | 'error'; message: string; location?: { source: string } }>;
24
+ }
25
+ export function renderArticleResult(input?: { markdown?: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string; sourcePath?: string; images?: Record<string, string> }): Promise<ArticleResult>;
26
+ export function prepareArticleResult(input: { sourcePath: string; markdown: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string }, readImage: (relative: string) => Promise<string>): Promise<ArticleResult>;
27
+ export function prepareArticleResultFromSource(input: { sourcePath: string; sourceBytes: Uint8Array;
28
+ platform?: WritingPlatform; theme?: WritingTheme; title?: string; creationSnapshotFingerprint?: string },
29
+ readImage: (relative: string) => Promise<string>): Promise<ArticleResult>;
30
+ export function articleLocalTextDocument(result: ArticleResult): { text_kind: 'platform_text'; title?: string; body: string } | null;