@getxflow/cli 0.10.0 → 0.10.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/dist/bin.js CHANGED
@@ -114,7 +114,7 @@ async function run(args) {
114
114
  await (0, env_1.envCheck)();
115
115
  return;
116
116
  }
117
- if (second === undefined || second === 'list' || second === 'pull') {
117
+ if (second === undefined || second === 'list') {
118
118
  await (0, env_1.envList)();
119
119
  return;
120
120
  }
@@ -216,6 +216,31 @@ function nudge() {
216
216
  (0, ui_1.note)((0, ui_1.dim)(` xflow ${latest} is out, ${version_1.CLI_VERSION} is installed: run xflow update`));
217
217
  (0, state_1.markNudged)(latest);
218
218
  }
219
+ /**
220
+ * The other staleness, and the one that costs more: the instructions an agent reads are
221
+ * older than the CLI answering it, and nothing about that is visible in the text. Louder
222
+ * than the notice above because it is wrong now rather than out of date soon, and it names
223
+ * the file, because a machine can hold a dozen copies and the wrong one gets edited.
224
+ *
225
+ * The command is skills --refresh, not update: what is out of step here is the copies, not
226
+ * the package, and refreshing needs no network, no npm and no rights on a global folder.
227
+ * The re-read is half the repair, so it is in the same breath as the command.
228
+ */
229
+ function nudgeSkill() {
230
+ const stale = (0, skills_1.staleSkillCopy)(process.cwd());
231
+ if (stale === null) {
232
+ // Everything matches, so the day-long silence has nothing left to protect: drop it,
233
+ // and a copy that drifts after a repair is news again instead of yesterday's answer.
234
+ (0, state_1.clearSkillNudges)();
235
+ return;
236
+ }
237
+ const key = `${stale} ${version_1.CLI_VERSION}`;
238
+ if (!(0, state_1.shouldNudgeSkill)(key))
239
+ return;
240
+ (0, ui_1.warn)(`${stale} is older than this CLI`);
241
+ (0, ui_1.note)((0, ui_1.dim)(' Run xflow skills --refresh, then read that file again: the refresh only writes it to disk'));
242
+ (0, state_1.markSkillNudged)(key);
243
+ }
219
244
  async function main() {
220
245
  const args = (0, args_1.parseArgs)(process.argv.slice(2));
221
246
  if ((0, args_1.flagBool)(args, 'version')) {
@@ -261,6 +286,10 @@ async function main() {
261
286
  finally {
262
287
  if (!quiet)
263
288
  nudge();
289
+ // Not behind the same flag: a copy out of step with the running package stays wrong
290
+ // whatever the command was, and when the package itself is the problem the copies
291
+ // match it and this says nothing anyway.
292
+ nudgeSkill();
264
293
  }
265
294
  }
266
295
  main().then((code) => {
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.replacePointer = replacePointer;
4
+ exports.copyIsStale = copyIsStale;
5
+ exports.staleSkillCopy = staleSkillCopy;
3
6
  exports.installSkillQuietly = installSkillQuietly;
4
7
  exports.skills = skills;
5
8
  const node_fs_1 = require("node:fs");
@@ -8,6 +11,7 @@ const node_path_1 = require("node:path");
8
11
  const args_1 = require("../args");
9
12
  const errors_1 = require("../errors");
10
13
  const prompt_1 = require("../prompt");
14
+ const update_1 = require("./update");
11
15
  const ui_1 = require("../ui");
12
16
  const AGENTS = [
13
17
  { id: 'claude', label: 'Claude Code', project: ['.claude', 'skills'], global: ['.claude', 'skills'] },
@@ -32,7 +36,8 @@ const POINTER = `${POINTER_MARKER}
32
36
  This project is hosted on the XFlow platform and ships through the \`xflow\` CLI, not
33
37
  through a git push. Read \`.agents/skills/xflow/SKILL.md\` before deploying, publishing,
34
38
  rolling back, touching the database or migrations, cloud functions, schedules,
35
- environment variables or production logs. Command list: \`xflow help\`.
39
+ environment variables, file storage and uploads, connected accounts or production logs.
40
+ Command list: \`xflow help\`.
36
41
  `;
37
42
  /** The skill ships inside the package, next to dist. */
38
43
  function skillSource() {
@@ -63,13 +68,43 @@ function writeIfChanged(path, content) {
63
68
  (0, node_fs_1.writeFileSync)(path, content, 'utf-8');
64
69
  return before === null ? 'new' : 'updated';
65
70
  }
66
- /** Append once, by marker: the file belongs to the user. */
67
- function appendPointer(base) {
71
+ /**
72
+ * Our own block put back in place, or null when the file has none.
73
+ *
74
+ * The block runs from the marker to the next heading of the same level, so whatever the
75
+ * user wrote around it survives. Appending once was enough while the text never changed;
76
+ * it does change, and a pointer that names half the sections is worse than an old CLI:
77
+ * the agent reads it every time, unlike the skill, which at least announces its own age.
78
+ */
79
+ function replacePointer(text) {
80
+ const start = text.indexOf(POINTER_MARKER);
81
+ if (start < 0)
82
+ return null;
83
+ // Past our own heading: the newline before it sits at start + length, so the search
84
+ // begins after it and the first hit is somebody else's section.
85
+ const after = text.indexOf('\n## ', start + POINTER_MARKER.length + 1);
86
+ const tail = after < 0 ? '' : `\n${text.slice(after + 1)}`;
87
+ return `${text.slice(0, start)}${POINTER}${tail}`;
88
+ }
89
+ /** The file belongs to the user: created only on install, rewritten only where ours is. */
90
+ function writePointer(base, create) {
68
91
  const path = (0, node_path_1.join)(base, 'AGENTS.md');
69
92
  const before = (0, node_fs_1.existsSync)(path) ? (0, node_fs_1.readFileSync)(path, 'utf-8') : null;
70
- if (before?.includes(POINTER_MARKER))
93
+ let next;
94
+ if (before === null) {
95
+ if (!create)
96
+ return null;
97
+ next = POINTER;
98
+ }
99
+ else {
100
+ const replaced = replacePointer(before);
101
+ if (replaced === null && !create)
102
+ return null;
103
+ next = replaced ?? `${before.replace(/\s*$/, '')}\n\n${POINTER}`;
104
+ }
105
+ if (before === next)
71
106
  return null;
72
- (0, node_fs_1.writeFileSync)(path, before ? `${before.replace(/\s*$/, '')}\n\n${POINTER}` : POINTER, 'utf-8');
107
+ (0, node_fs_1.writeFileSync)(path, next, 'utf-8');
73
108
  return { path, state: before === null ? 'new' : 'updated' };
74
109
  }
75
110
  function install(base, ids, global) {
@@ -93,12 +128,88 @@ function install(base, ids, global) {
93
128
  }
94
129
  // Project-level only: these have no global equivalents.
95
130
  if (!global) {
96
- const pointer = appendPointer(base);
131
+ const pointer = writePointer(base, true);
97
132
  if (pointer)
98
133
  changes.push(pointer);
99
134
  }
100
135
  return changes;
101
136
  }
137
+ /**
138
+ * Every place a copy can live, deduplicated, project copies first. Two consumers read
139
+ * this list: the refresh below and the staleness check the nudge runs, and they have to
140
+ * agree on what a copy is, or one of them would report the other's work as undone.
141
+ *
142
+ * The Cursor rule is not one of the agent paths and not a plain copy either: it carries
143
+ * the same body under its own header, hence the flag rather than a second list.
144
+ */
145
+ function copyPaths(base) {
146
+ const paths = [];
147
+ const seen = new Set();
148
+ const add = (path, rule) => {
149
+ if (seen.has(path))
150
+ return;
151
+ seen.add(path);
152
+ paths.push({ path, rule });
153
+ };
154
+ for (const agent of AGENTS)
155
+ add(skillPath(base, agent, false), false);
156
+ add((0, node_path_1.join)(base, ...CURSOR_RULE), true);
157
+ // A source checkout holds the skill as it is being written, not as it was published,
158
+ // while the home copies are read from every project on this machine. Refreshing them
159
+ // from here would hand a draft to every agent on the machine, and reporting them stale
160
+ // would nag for ever with a command we do not want run. So from a checkout the CLI
161
+ // minds this folder and nothing else, and both consumers of the list agree because it
162
+ // is decided here rather than twice.
163
+ if ((0, update_1.classifyPath)(__dirname) !== 'checkout') {
164
+ for (const agent of AGENTS)
165
+ add(skillPath(base, agent, true), false);
166
+ }
167
+ return paths;
168
+ }
169
+ /**
170
+ * The stamp names the CLI version, so it moves on every release even when not a word of
171
+ * the instructions changed. Comparing it would call every copy on the machine stale after
172
+ * such a release, which is exactly what comparing content instead of versions avoids.
173
+ */
174
+ const STAMP = /^These instructions ship with xflow CLI .*$/m;
175
+ /**
176
+ * Line endings are not a difference either. An editor that saved a copy as CRLF would
177
+ * otherwise leave the machine announcing a stale skill for ever, and the refresh that
178
+ * "fixes" it would change nothing visible. Same normalisation the template gate uses.
179
+ */
180
+ function copyIsStale(expected, actual) {
181
+ return compared(expected) !== compared(actual);
182
+ }
183
+ function compared(text) {
184
+ return text.replace(/\r\n/g, '\n').replace(STAMP, '');
185
+ }
186
+ /**
187
+ * The first installed copy whose text is not what this CLI ships, or null when every
188
+ * copy matches. Compared by content rather than by a version stamp: a release that left
189
+ * the skill alone must not declare identical copies stale, and copies old enough to
190
+ * carry no stamp still have to be recognised.
191
+ *
192
+ * Never throws: this runs after a command that already succeeded, and an unreadable file
193
+ * somewhere in a home folder is not a reason to fail it.
194
+ */
195
+ function staleSkillCopy(base) {
196
+ try {
197
+ const present = copyPaths(base).filter((copy) => (0, node_fs_1.existsSync)(copy.path));
198
+ if (present.length === 0)
199
+ return null;
200
+ const skill = skillSource();
201
+ const rule = cursorRule(skill);
202
+ for (const copy of present) {
203
+ const expected = copy.rule ? rule : skill;
204
+ if (copyIsStale(expected, (0, node_fs_1.readFileSync)(copy.path, 'utf-8')))
205
+ return copy.path;
206
+ }
207
+ return null;
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
102
213
  /**
103
214
  * Rewrite every copy that already exists and create none: this is what xflow update
104
215
  * calls, and it runs wherever the user happened to stand. Installing defaults here
@@ -106,24 +217,20 @@ function install(base, ids, global) {
106
217
  */
107
218
  function refreshInstalled(base) {
108
219
  const skill = skillSource();
220
+ const rule = cursorRule(skill);
109
221
  const changes = [];
110
- const seen = new Set();
111
- const put = (path, content) => {
112
- if (seen.has(path) || !(0, node_fs_1.existsSync)(path))
113
- return;
114
- seen.add(path);
115
- const state = writeIfChanged(path, content);
222
+ for (const copy of copyPaths(base)) {
223
+ if (!(0, node_fs_1.existsSync)(copy.path))
224
+ continue;
225
+ const state = writeIfChanged(copy.path, copy.rule ? rule : skill);
116
226
  if (state)
117
- changes.push({ path, state });
118
- };
119
- for (const agent of AGENTS) {
120
- put(skillPath(base, agent, false), skill);
121
- put(skillPath(base, agent, true), skill);
122
- // The Cursor rule is not one of the agent paths: without this line it keeps the
123
- // old text next to a fresh SKILL.md, and the two drift apart unnoticed.
124
- if (agent.rule === true)
125
- put((0, node_path_1.join)(base, ...CURSOR_RULE), cursorRule(skill));
227
+ changes.push({ path: copy.path, state });
126
228
  }
229
+ // AGENTS.md is read on every task, unlike the lazily loaded skill, so a stale pointer
230
+ // there misleads more often than a stale skill. Rewritten, never created.
231
+ const pointer = writePointer(base, false);
232
+ if (pointer)
233
+ changes.push(pointer);
127
234
  return changes;
128
235
  }
129
236
  /** Defaults plus whatever is already installed, so an update run refreshes everything. */
@@ -150,10 +150,12 @@ function update() {
150
150
  (0, ui_1.note)((0, ui_1.dim)(' Check what is on disk now: xflow --version'));
151
151
  return;
152
152
  }
153
- if (after === version_1.CLI_VERSION) {
153
+ if (after === version_1.CLI_VERSION)
154
154
  (0, ui_1.ok)(`xflow ${after} is the latest version`);
155
- return;
156
- }
157
- (0, ui_1.ok)(`xflow ${version_1.CLI_VERSION} ${after}`);
155
+ else
156
+ (0, ui_1.ok)(`xflow ${version_1.CLI_VERSION} → ${after}`);
157
+ // Also when the version did not move: copies drift on their own, from a global install
158
+ // made by npm directly or a folder that never saw this command. The refresh writes
159
+ // nothing where the text already matches, so the quiet case stays quiet.
158
160
  refreshSkill(root);
159
161
  }