sfora-cli 0.13.1 → 0.15.0

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.
@@ -0,0 +1,75 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { diffSkillBundles, installSkillBundle, readSkillBundle, scanLocalSkills, uninstallSkill, validateSkillBundle } from "./local-core/index.js";
3
+ import { SkillsClient } from "./skills-client.js";
4
+ export const SKILLS_HELP = `Skills:
5
+ sfora skills scan [root ...] --json Discover complete local skill bundles
6
+ sfora skills list --project <slug> List cloud project skills
7
+ sfora skills push <folder> --project <slug> --expected-version <N> [--expected-revision <N>]
8
+ Upload draft and publish (0 for a new skill)
9
+ sfora skills pull <name> <bundle.json> --project <slug> [--version <N>]
10
+ sfora skills install <name> --project <slug> --skills-target <directory> [--version <N>]
11
+ sfora skills install-file <bundle.json> --skills-target <directory>
12
+ sfora skills diff <folder> <name> --project <slug> [--version <N>]
13
+ sfora skills uninstall <installed-folder> Remove only an unchanged Sfora-owned install
14
+ `;
15
+ export async function runSkillsCommand(args, settings) {
16
+ const [verb, first, second] = args.rest;
17
+ for (const [label, value] of [["version", args.skillVersion], ["expected-version", args.expectedVersion], ["expected-revision", args.expectedRevision]]) {
18
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < (label === "version" ? 1 : 0)))
19
+ throw new Error(`--${label} must be a ${label === "version" ? "positive" : "non-negative"} integer.`);
20
+ }
21
+ const print = (value) => console.log(JSON.stringify(value, null, args.json ? undefined : 2));
22
+ const need = (value, label) => { if (!value)
23
+ throw new Error(`${label} is required.\n${SKILLS_HELP}`); return value; };
24
+ if (verb === "scan") {
25
+ print(await scanLocalSkills(args.rest.length > 1 ? args.rest.slice(1) : undefined));
26
+ return;
27
+ }
28
+ if (verb === "uninstall") {
29
+ await uninstallSkill(need(first, "Installed folder"));
30
+ print({ uninstalled: first });
31
+ return;
32
+ }
33
+ if (verb === "install-file") {
34
+ const bundle = JSON.parse(await readFile(need(first, "Bundle JSON"), "utf8"));
35
+ validateSkillBundle(bundle);
36
+ print(await installSkillBundle(bundle, need(args.skillsTarget, "--skills-target"), first));
37
+ return;
38
+ }
39
+ const project = need(args.project, "--project");
40
+ const client = new SkillsClient(settings);
41
+ if (verb === "list") {
42
+ print(await client.list(project));
43
+ return;
44
+ }
45
+ if (verb === "push") {
46
+ if (args.expectedVersion === undefined)
47
+ throw new Error("--expected-version is required (0 for a new skill). Read the current version with skills list first.");
48
+ const bundle = await readSkillBundle(need(first, "Skill folder"));
49
+ const draft = await client.saveDraft(project, bundle, args.expectedVersion, args.expectedRevision);
50
+ print(await client.publish(project, bundle.name, args.expectedVersion, draft.draftRevision));
51
+ return;
52
+ }
53
+ if (verb === "pull") {
54
+ const bundle = await client.download(project, need(first, "Skill name"), args.skillVersion);
55
+ // Export refuses to overwrite unrelated files; explicit repeated exports can choose a new path.
56
+ const { atomicCreate } = await import("./local-core/files.js");
57
+ await atomicCreate(need(second, "Output bundle JSON"), `${JSON.stringify(bundle, null, 2)}\n`);
58
+ print({ path: second, hash: bundle.hash });
59
+ return;
60
+ }
61
+ if (verb === "install") {
62
+ const name = need(first, "Skill name");
63
+ const version = args.skillVersion ?? (await client.detail(project, name)).skill.version;
64
+ if (!Number.isSafeInteger(version) || version < 1)
65
+ throw new Error("This skill has no published version to install.");
66
+ const bundle = await client.download(project, name, version);
67
+ print(await installSkillBundle(bundle, need(args.skillsTarget, "--skills-target"), `${settings.url}/${project}/${name}`, version));
68
+ return;
69
+ }
70
+ if (verb === "diff") {
71
+ print(diffSkillBundles(await readSkillBundle(need(first, "Local folder")), await client.download(project, need(second, "Skill name"), args.skillVersion)));
72
+ return;
73
+ }
74
+ throw new Error(SKILLS_HELP);
75
+ }
@@ -0,0 +1 @@
1
+ export declare const CLI_VERSION = "0.15.0";
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/sync-format.mjs from package.json.
2
+ export const CLI_VERSION = "0.15.0";
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "sfora-cli",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
- "description": "Your sfora workspace as a markdown filesystem — a CLI + MCP server. Post/task/doc, ls/cat/grep, and a shell so agents operate sfora natively.",
5
+ "description": "Your sfora workspace as a markdown filesystem \u2014 a CLI + MCP server. Post/task/doc, ls/cat/grep, and a shell so agents operate sfora natively.",
6
6
  "keywords": [
7
7
  "sfora",
8
8
  "mcp",
@@ -52,5 +52,23 @@
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=20"
55
+ },
56
+ "exports": {
57
+ ".": {
58
+ "types": "./dist/index.d.ts",
59
+ "import": "./dist/index.js"
60
+ },
61
+ "./local-core": {
62
+ "types": "./dist/local-core/index.d.ts",
63
+ "import": "./dist/local-core/index.js"
64
+ },
65
+ "./config": {
66
+ "types": "./dist/config.d.ts",
67
+ "import": "./dist/config.js"
68
+ },
69
+ "./skills-client": {
70
+ "types": "./dist/skills-client.d.ts",
71
+ "import": "./dist/skills-client.js"
72
+ }
55
73
  }
56
74
  }
@@ -1 +0,0 @@
1
- export {};
@@ -1,94 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { resolveSettings, upsertProfile, effectiveProfiles, profileKey, DEFAULT_URL, } from "./config.js";
3
- const DEV_URL = "https://valuable-llama-777.eu-west-1.convex.site";
4
- // resolveSettings reads SFORA_* env as a fallback — clear them so tests assert
5
- // the config/flag behavior in isolation.
6
- beforeEach(() => {
7
- delete process.env.SFORA_URL;
8
- delete process.env.SFORA_ORG;
9
- delete process.env.SFORA_API_KEY;
10
- });
11
- describe("legacy migration", () => {
12
- it("folds a legacy top-level identity into a profile", () => {
13
- const cfg = {
14
- url: DEFAULT_URL,
15
- org: "wavyr",
16
- apiKey: "sfora_ak_prod",
17
- };
18
- const profiles = effectiveProfiles(cfg);
19
- expect(profiles[profileKey(DEFAULT_URL, "wavyr")]).toEqual({
20
- url: DEFAULT_URL,
21
- org: "wavyr",
22
- apiKey: "sfora_ak_prod",
23
- });
24
- });
25
- it("resolves the sole legacy identity with no flags", () => {
26
- const cfg = {
27
- url: DEFAULT_URL,
28
- org: "wavyr",
29
- apiKey: "sfora_ak_prod",
30
- };
31
- expect(resolveSettings({}, cfg)).toEqual({
32
- url: DEFAULT_URL,
33
- org: "wavyr",
34
- apiKey: "sfora_ak_prod",
35
- });
36
- });
37
- });
38
- describe("no cross-deployment key reuse", () => {
39
- it("does NOT send the prod key to an unmatched dev target", () => {
40
- const cfg = {
41
- url: DEFAULT_URL,
42
- org: "wavyr",
43
- apiKey: "sfora_ak_prod",
44
- };
45
- const r = resolveSettings({ url: DEV_URL, org: "test" }, cfg);
46
- expect(r.url).toBe(DEV_URL);
47
- expect(r.org).toBe("test");
48
- // The whole point: no key rather than the wrong key.
49
- expect(r.apiKey).toBeUndefined();
50
- });
51
- });
52
- describe("profiles coexist (the fix)", () => {
53
- it("login to dev does not clobber the prod key; each resolves by intent", () => {
54
- let cfg = {
55
- url: DEFAULT_URL,
56
- org: "wavyr",
57
- apiKey: "sfora_ak_prod",
58
- };
59
- // Simulate `sfora login` against dev.
60
- cfg = upsertProfile(cfg, {
61
- url: DEV_URL,
62
- org: "test",
63
- apiKey: "sfora_ak_dev",
64
- }).cfg;
65
- // Both keys are retained.
66
- const profiles = effectiveProfiles(cfg);
67
- expect(Object.keys(profiles)).toHaveLength(2);
68
- // Prod still reachable by intent.
69
- expect(resolveSettings({ org: "wavyr" }, cfg).apiKey).toBe("sfora_ak_prod");
70
- expect(resolveSettings({ url: DEFAULT_URL }, cfg).apiKey).toBe("sfora_ak_prod");
71
- // Dev reachable by intent.
72
- expect(resolveSettings({ org: "test" }, cfg).apiKey).toBe("sfora_ak_dev");
73
- // No intent → the last login (active profile) wins.
74
- expect(resolveSettings({}, cfg)).toEqual({
75
- url: DEV_URL,
76
- org: "test",
77
- apiKey: "sfora_ak_dev",
78
- });
79
- });
80
- });
81
- describe("bot identity", () => {
82
- it("uses the named bot's key and never the personal fallback", () => {
83
- const cfg = {
84
- url: DEFAULT_URL,
85
- org: "wavyr",
86
- apiKey: "sfora_ak_prod",
87
- bots: { ci: { apiKey: "sfora_ak_ci", org: "wavyr" } },
88
- };
89
- const r = resolveSettings({ bot: "ci" }, cfg);
90
- expect(r.apiKey).toBe("sfora_ak_ci");
91
- const missing = resolveSettings({ bot: "nope" }, cfg);
92
- expect(missing.apiKey).toBeUndefined();
93
- });
94
- });