knowingfyi 0.3.0 → 0.3.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
@@ -55,7 +55,7 @@ Publishing and source-grounded docs are also available through `publish`, `sync`
55
55
  npx knowingfyi login --site https://your-knowing-host
56
56
  ```
57
57
 
58
- The browser approval stores a customer token locally for 30 days, and the commands send it to that host. On another machine or in CI, set `KNOWING_CUSTOMER_TOKEN` with `KNOWING_SITE` naming the same host. `logout` removes the local token; revoke the terminal under Authorized devices to disable it. Customer-token reading, `knowing token`, `--install-only` and the `KNOWING_SITE` check arrive with the release after 0.1.1.
58
+ The browser approval stores a customer token locally until revoked, and the commands send it to that host. On another machine or in CI, set `KNOWING_CUSTOMER_TOKEN` with `KNOWING_SITE` naming the same host. `logout` removes the local token; revoke the terminal under Authorized devices to disable it. Customer-token reading, `knowing token`, `--install-only` and the `KNOWING_SITE` check arrive with the release after 0.1.1.
59
59
 
60
60
  ## When your Knowing host changes
61
61
 
@@ -66,3 +66,5 @@ Update `KNOWING_SITE`, MCP server URLs and any vendored workflow project URLs de
66
66
  The CLI rejects API redirects, and publishing/install secrets have different capabilities from customer logins. Existing self-hosted and local origins remain supported. Operators can use the [platform domain cutover runbook](https://github.com/BrickspaceLab/knowing/blob/main/docs/knowing/platform-domain-cutover.md) to plan compatibility and rollback.
67
67
 
68
68
  Node.js 24 or newer is required. See [authoring skills and source configuration](https://github.com/BrickspaceLab/knowing/blob/main/docs/knowing/release-source.md).
69
+
70
+ Interactive native plugin installation installs and registers available free skills first, then opens account authorization and waits for paid access before upgrading the same pinned plugin and registering it again. `--free` skips the upgrade; CI and install-token runs never wait for browser checkout. Locally modified plugin files are preserved. This flow requires the next npm CLI release.
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
6
6
  import {
7
7
  mkdir,
8
8
  readFile,
9
+ readdir,
9
10
  writeFile,
10
11
  lstat,
11
12
  realpath,
@@ -184,9 +185,43 @@ async function safeDirectory(directory) {
184
185
  throw new Error(`Unsafe directory: ${current}`);
185
186
  }
186
187
  }
188
+ async function identicalSkill(directory, files, prefix) {
189
+ const pending = [directory];
190
+ const expected = new Map(
191
+ files
192
+ .filter((file) => file.path.startsWith(prefix))
193
+ .map((file) => [file.path.slice(prefix.length), file]),
194
+ );
195
+ if ((await lstat(directory)).isSymbolicLink()) return false;
196
+ let count = 0;
197
+ while (pending.length) {
198
+ const current = pending.pop();
199
+ for (const entry of await readdir(current, { withFileTypes: true })) {
200
+ const target = path.join(current, entry.name);
201
+ if (entry.isSymbolicLink()) return false;
202
+ if (entry.isDirectory()) {
203
+ pending.push(target);
204
+ continue;
205
+ }
206
+ const file = expected.get(
207
+ path.relative(directory, target).split(path.sep).join("/"),
208
+ );
209
+ if (!entry.isFile() || !file) return false;
210
+ const content = Buffer.from(
211
+ file.content,
212
+ file.encoding === "base64" ? "base64" : "utf8",
213
+ );
214
+ if (!(await readFile(target)).equals(content)) return false;
215
+ count++;
216
+ }
217
+ }
218
+ return count === expected.size;
219
+ }
187
220
  export async function installArtifact(artifact, options = {}) {
188
221
  const cwd = await realpath(path.resolve(options.cwd ?? process.cwd()));
189
222
  const created = [];
223
+ const reused = new Set();
224
+ const backups = new Map();
190
225
  try {
191
226
  if (["skills", "project-skill", "space-skill"].includes(artifact.format)) {
192
227
  const base = path.join(
@@ -205,12 +240,34 @@ export async function installArtifact(artifact, options = {}) {
205
240
  if (!names.length) throw new Error("The release contains no skills.");
206
241
  for (const name of names) {
207
242
  const dest = path.join(base, name);
208
- await mkdir(dest);
209
- created.push(dest);
243
+ try {
244
+ await mkdir(dest);
245
+ created.push(dest);
246
+ } catch (error) {
247
+ if (error.code !== "EEXIST" || !options.reuseIdentical) throw error;
248
+ const prefix = `skills/${name}/`;
249
+ if (await identicalSkill(dest, artifact.files, prefix)) {
250
+ reused.add(dest);
251
+ } else if (
252
+ options.previousArtifact?.files.some((file) =>
253
+ file.path.startsWith(prefix),
254
+ ) &&
255
+ (await identicalSkill(dest, options.previousArtifact.files, prefix))
256
+ ) {
257
+ const backup = `${dest}.upgrade-${randomBytes(12).toString("hex")}`;
258
+ await rename(dest, backup);
259
+ backups.set(dest, backup);
260
+ await mkdir(dest);
261
+ created.push(dest);
262
+ } else {
263
+ throw error;
264
+ }
265
+ }
210
266
  }
211
267
  for (const file of artifact.files.filter((f) =>
212
268
  f.path.startsWith("skills/"),
213
269
  )) {
270
+ if (reused.has(path.join(base, file.path.split("/")[1]))) continue;
214
271
  const dest = path.join(base, file.path.slice(7));
215
272
  await safeDirectory(path.dirname(dest));
216
273
  await writeFile(
@@ -229,9 +286,27 @@ export async function installArtifact(artifact, options = {}) {
229
286
  );
230
287
  await safeDirectory(base);
231
288
  const dest = path.join(base, artifact.format);
232
- await mkdir(dest);
233
- created.push(dest);
234
- for (const file of artifact.files) {
289
+ try {
290
+ await mkdir(dest);
291
+ created.push(dest);
292
+ } catch (error) {
293
+ if (error.code !== "EEXIST" || !options.reuseIdentical) throw error;
294
+ if (await identicalSkill(dest, artifact.files, "")) {
295
+ reused.add(dest);
296
+ } else if (
297
+ options.previousArtifact &&
298
+ (await identicalSkill(dest, options.previousArtifact.files, ""))
299
+ ) {
300
+ const backup = `${dest}.upgrade-${randomBytes(12).toString("hex")}`;
301
+ await rename(dest, backup);
302
+ backups.set(dest, backup);
303
+ await mkdir(dest);
304
+ created.push(dest);
305
+ } else {
306
+ throw error;
307
+ }
308
+ }
309
+ for (const file of reused.has(dest) ? [] : artifact.files) {
235
310
  const output = path.join(dest, file.path);
236
311
  await safeDirectory(path.dirname(output));
237
312
  await writeFile(
@@ -241,10 +316,14 @@ export async function installArtifact(artifact, options = {}) {
241
316
  );
242
317
  }
243
318
  }
244
- return created;
319
+ for (const backup of backups.values())
320
+ await rm(backup, { recursive: true }).catch(() => {});
321
+ return [...created, ...reused];
245
322
  } catch (error) {
246
323
  for (const directory of created.reverse())
247
324
  await rm(directory, { recursive: true, force: true });
325
+ for (const [destination, backup] of backups)
326
+ await rename(backup, destination);
248
327
  if (error.code === "EEXIST")
249
328
  throw new Error(
250
329
  "A destination already exists. Existing files were preserved; choose another project or remove the old installation yourself.",
@@ -646,7 +725,7 @@ async function setupRelease(command, target, flag, switches) {
646
725
  }
647
726
  if (requiresToken)
648
727
  print(
649
- `Set the KNOWING_INSTALL_TOKEN repository secret to an install-only token with access to this release:\nknowing login --site ${origin} --install-only | gh secret set KNOWING_INSTALL_TOKEN\nIt only installs releases, never reads your projects or uses hosted answers, expires after 30 days and can be revoked from Authorized devices.`,
728
+ `Set the KNOWING_INSTALL_TOKEN repository secret to an install-only token with access to this release:\nknowing login --site ${origin} --install-only | gh secret set KNOWING_INSTALL_TOKEN\nIt only installs releases, never reads your projects or uses hosted answers, stays active until revoked from Authorized devices.`,
650
729
  );
651
730
  print(
652
731
  `Created ${file}\nRelease ${release.version}, task ${setup.task.name}. Commit and push after reviewing the workflow.`,
@@ -680,6 +759,23 @@ async function setupRelease(command, target, flag, switches) {
680
759
  ),
681
760
  expected,
682
761
  );
762
+ const freeExpected = { ...expected, tier: "free" };
763
+ let previousArtifact;
764
+ if (
765
+ !options.free &&
766
+ (options.premium || (release.tier ?? 0) > 0) &&
767
+ !setup.skill
768
+ ) {
769
+ try {
770
+ previousArtifact = validateArtifact(
771
+ await rpc(origin, "artifact", freeExpected, false),
772
+ freeExpected,
773
+ );
774
+ } catch (error) {
775
+ // A paid-only or private release has no anonymous free installation to upgrade.
776
+ if (!["FORBIDDEN", "NOT_FOUND"].includes(error.code)) throw error;
777
+ }
778
+ }
683
779
  // Create the MCP file first so an existing configuration cannot leave a partial skill installation.
684
780
  let mcpFile;
685
781
  if (command === "add-agent" && setup.format === "skills" && release.mcp) {
@@ -698,7 +794,11 @@ async function setupRelease(command, target, flag, switches) {
698
794
  }
699
795
  let directories;
700
796
  try {
701
- directories = await installArtifact(artifact, { agent: setup.agent });
797
+ directories = await installArtifact(artifact, {
798
+ agent: setup.agent,
799
+ reuseIdentical: true,
800
+ previousArtifact,
801
+ });
702
802
  } catch (error) {
703
803
  if (mcpFile) await rm(mcpFile);
704
804
  throw error;
@@ -710,11 +810,20 @@ async function setupRelease(command, target, flag, switches) {
710
810
  command === "add-skill" &&
711
811
  !setup.skill &&
712
812
  !options.free &&
813
+ !options.premium &&
713
814
  !release.fullAccess
714
- )
815
+ ) {
816
+ const upgradeUrl = releaseAccessUrl(origin, release.releaseId);
715
817
  print(
716
- `More of this release is in paid tiers: ${releaseAccessUrl(origin, release.releaseId)}`,
818
+ `Free skills installed. Compare paid tiers and their benefits: ${upgradeUrl}`,
717
819
  );
820
+ if (
821
+ !switches.noBrowser &&
822
+ !process.env.CI &&
823
+ !process.env.KNOWING_INSTALL_TOKEN
824
+ )
825
+ openBrowser(upgradeUrl);
826
+ }
718
827
  const registrationDirectory = await preparePluginRegistration(
719
828
  artifact,
720
829
  directories[0],
@@ -734,6 +843,40 @@ async function setupRelease(command, target, flag, switches) {
734
843
  }
735
844
  print("Plugin registered. Restart your coding agent to load it.");
736
845
  }
846
+ if (
847
+ command === "add-agent" &&
848
+ artifact.format.endsWith("-plugin") &&
849
+ !options.free &&
850
+ !options.premium &&
851
+ !release.fullAccess &&
852
+ // Only a free installation can be replaced; a paid tier's files differ from it.
853
+ !((release.tier ?? 0) > 0) &&
854
+ process.stdin.isTTY &&
855
+ !process.env.CI &&
856
+ !process.env.KNOWING_INSTALL_TOKEN
857
+ ) {
858
+ print(
859
+ "Free plugin installed. Continue in your browser to sign in and compare paid tiers. The plugin upgrades automatically after purchase.",
860
+ );
861
+ try {
862
+ await setupRelease(
863
+ command,
864
+ target,
865
+ (name) =>
866
+ name === "agent"
867
+ ? setup.agent
868
+ : name === "version"
869
+ ? release.version
870
+ : flag(name),
871
+ { ...switches, premium: true },
872
+ );
873
+ } catch (error) {
874
+ // A failed upgrade rolls back its files; the free installation above already succeeded.
875
+ print(
876
+ `Paid upgrade did not finish: ${error.message} Compare paid tiers: ${releaseAccessUrl(origin, release.releaseId)}`,
877
+ );
878
+ }
879
+ }
737
880
  return;
738
881
  } catch (error) {
739
882
  if (!["FORBIDDEN", "UNAUTHORIZED"].includes(error.code)) {
@@ -811,9 +954,9 @@ async function setupRelease(command, target, flag, switches) {
811
954
  }
812
955
  }
813
956
  }
814
- /** The release's access options, through sign-in when the browser needs it. */
957
+ /** Public plan comparison; authentication happens when acquiring access. */
815
958
  export function releaseAccessUrl(origin, releaseId) {
816
- return `${origin}/join?callbackURL=${encodeURIComponent(`/skills?release=${encodeURIComponent(releaseId)}`)}`;
959
+ return `${origin}/skills/releases/${encodeURIComponent(releaseId)}`;
817
960
  }
818
961
  /**
819
962
  * Polls an install's access while the buyer can still act in the browser,
@@ -857,11 +1000,21 @@ export async function preparePluginRegistration(artifact, directory) {
857
1000
  ).content,
858
1001
  );
859
1002
  catalog.plugins[0].source.path = `./${path.basename(directory)}`;
860
- await writeSetupFile(
861
- ".agents/plugins/marketplace.json",
862
- JSON.stringify(catalog, null, 2),
863
- root,
864
- );
1003
+ const file = ".agents/plugins/marketplace.json";
1004
+ const content = JSON.stringify(catalog, null, 2);
1005
+ const destination = path.join(root, file);
1006
+ try {
1007
+ const stat = await lstat(destination);
1008
+ if (
1009
+ !stat.isSymbolicLink() &&
1010
+ stat.isFile() &&
1011
+ (await readFile(destination, "utf8")) === content
1012
+ )
1013
+ return root;
1014
+ } catch (error) {
1015
+ if (error.code !== "ENOENT") throw error;
1016
+ }
1017
+ await writeSetupFile(file, content, root);
865
1018
  return root;
866
1019
  }
867
1020
  export async function registerPlugin(artifact, directory, run = runFile) {
@@ -994,7 +1147,7 @@ export async function main(args) {
994
1147
  );
995
1148
  print(result.token);
996
1149
  note(
997
- "Store this install-only token as the KNOWING_INSTALL_TOKEN repository secret. It isn't saved on this machine and expires in 30 days.",
1150
+ "Store this install-only token as the KNOWING_INSTALL_TOKEN repository secret. It isn't saved on this machine and stays active until revoked.",
998
1151
  );
999
1152
  return;
1000
1153
  }
@@ -1002,7 +1155,9 @@ export async function main(args) {
1002
1155
  await saveConfiguration(file, { token: result.token });
1003
1156
  const defaults = path.join(path.dirname(file), "default-site.json");
1004
1157
  await saveConfiguration(defaults, { origin });
1005
- print("Connected. Your token is stored locally and expires in 30 days.");
1158
+ print(
1159
+ "Connected. Your token is stored locally and stays active until revoked.",
1160
+ );
1006
1161
  return;
1007
1162
  }
1008
1163
  throw new Error("Login expired. Start login again.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowingfyi",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Install source-defined Knowing skills and agents and configure GitHub Actions.",
5
5
  "type": "module",
6
6
  "bin": {