create-obsidian-arrow 0.1.0 → 0.1.8

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,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * component-hash — sandbox -> plugin porting parity check.
4
+ *
5
+ * The sandbox is the source of truth for a component; the plugin gets a
6
+ * near-verbatim copy. This hashes the canonical *portable body* (imports +
7
+ * whitespace normalized out; see lib/canonical-source.mjs) so the hook flags
8
+ * only meaningful drift, not benign import/mount deltas.
9
+ *
10
+ * Usage:
11
+ * node scripts/component-hash.mjs <file>
12
+ * print the canonical hash of one component.
13
+ *
14
+ * node scripts/component-hash.mjs --verify <sandbox-file> <plugin-file>
15
+ * compare two files; exit 0 (PARITY OK) or 1 (DRIFT).
16
+ *
17
+ * node scripts/component-hash.mjs --check <manifest.json> [--update]
18
+ * check every entry; exit 1 on any drift. --update re-records the baseline
19
+ * from the source of truth (sandbox if present, else the plugin copy).
20
+ *
21
+ * Manifest shape (paths resolved relative to the manifest file):
22
+ * { "entries": [
23
+ * { "plugin": "src/chat/arrow/Foo.ts",
24
+ * "sandbox": "../obsidian-arrow-sandbox/src/components/Foo.ts", // optional
25
+ * "hash": "<sha256>" } // optional fallback
26
+ * ] }
27
+ *
28
+ * Pure Node, no dependencies. Runs at the dev/commit boundary (husky/CI).
29
+ */
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ import { hashSource } from "./lib/canonical-source.mjs";
33
+
34
+ function fail(message) {
35
+ console.error(`component-hash: ${message}`);
36
+ process.exit(2);
37
+ }
38
+
39
+ function hashFile(file) {
40
+ try {
41
+ return hashSource(fs.readFileSync(file, "utf8"));
42
+ } catch (error) {
43
+ fail(`cannot read ${file} (${error.code ?? error.message})`);
44
+ return ""; // unreachable; fail() exits
45
+ }
46
+ }
47
+
48
+ const argv = process.argv.slice(2);
49
+
50
+ if (argv.length === 0) {
51
+ fail("usage: component-hash <file> | --verify <a> <b> | --check <manifest> [--update]");
52
+ }
53
+
54
+ if (argv[0] === "--verify") {
55
+ const [, a, b] = argv;
56
+ if (!a || !b) {
57
+ fail("--verify needs <sandbox-file> <plugin-file>");
58
+ }
59
+ const ha = hashFile(a);
60
+ const hb = hashFile(b);
61
+ if (ha === hb) {
62
+ console.log(`PARITY OK ${path.basename(b)} ${ha.slice(0, 12)}`);
63
+ process.exit(0);
64
+ }
65
+ console.error(`DRIFT ${b}\n sandbox ${ha.slice(0, 12)} != plugin ${hb.slice(0, 12)}`);
66
+ console.error(" -> edit the sandbox component and re-port (don't hand-edit the copy).");
67
+ process.exit(1);
68
+ }
69
+
70
+ if (argv[0] === "--check") {
71
+ const manifestPath = argv[1];
72
+ const update = argv.includes("--update");
73
+ if (!manifestPath) {
74
+ fail("--check needs <manifest.json>");
75
+ }
76
+ const manifestDir = path.dirname(path.resolve(manifestPath));
77
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
78
+ const entries = manifest.entries ?? [];
79
+ const resolve = (p) => path.resolve(manifestDir, p);
80
+
81
+ const drifts = [];
82
+ for (const entry of entries) {
83
+ const pluginHash = hashFile(resolve(entry.plugin));
84
+ const hasSandbox = entry.sandbox && fs.existsSync(resolve(entry.sandbox));
85
+ const sourceHash = hasSandbox ? hashFile(resolve(entry.sandbox)) : undefined;
86
+ const expected = sourceHash ?? entry.hash;
87
+
88
+ if (update) {
89
+ entry.hash = sourceHash ?? pluginHash;
90
+ continue;
91
+ }
92
+ if (expected === undefined) {
93
+ drifts.push(`${entry.plugin}: no reference (add a sandbox path or a recorded hash)`);
94
+ } else if (pluginHash !== expected) {
95
+ const ref = hasSandbox ? "sandbox" : "recorded";
96
+ drifts.push(
97
+ `${entry.plugin}: DRIFT (plugin ${pluginHash.slice(0, 12)} != ${ref} ${expected.slice(0, 12)})`
98
+ );
99
+ }
100
+ }
101
+
102
+ if (update) {
103
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`);
104
+ console.log(
105
+ `Re-recorded ${entries.length} baseline hash(es) in ${path.basename(manifestPath)}.`
106
+ );
107
+ process.exit(0);
108
+ }
109
+
110
+ if (drifts.length > 0) {
111
+ console.error(`Port parity: ${drifts.length} drift(s).`);
112
+ for (const d of drifts) {
113
+ console.error(` ${d}`);
114
+ }
115
+ console.error(" -> edit the sandbox component and re-port, or --update to re-bless.");
116
+ process.exit(1);
117
+ }
118
+ console.log(`Port parity OK (${entries.length} component(s)).`);
119
+ process.exit(0);
120
+ }
121
+
122
+ // Default: print the hash of one file.
123
+ console.log(hashFile(argv[0]));
@@ -1,47 +1,155 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Install the skills bundled in this repo (skills/*) into the developer's agent
4
- * via the `skills` CLI (https://github.com/vercel-labs/skills). This repo acts
5
- * as a local marketplace: `skills add .` discovers skills/<name>/SKILL.md and
6
- * lets the user pick which to install through the interactive TUI.
3
+ * Install / update the skills bundled in this repo (skills/*) via the `skills`
4
+ * CLI (https://github.com/vercel-labs/skills). This repo is a local skills
5
+ * marketplace: the source `.` resolves to skills/<name>/SKILL.md.
7
6
  *
8
- * Wired as `postinstall`, so it runs after `pnpm install`. To keep that safe it
9
- * only launches the interactive TUI when attached to a real terminal — in CI or
10
- * any non-interactive context it prints a hint and exits 0 instead of hanging.
11
- * Run it on demand with `pnpm skills:install` (which passes --force).
7
+ * Modes (auto-detected):
8
+ * - Interactive terminal → opens the picker TUI (choose what to install).
9
+ * - Non-interactive / CI / -y → installs ALL bundled skills, no prompts.
10
+ * - --update → updates an already-installed setup in place.
12
11
  *
13
- * SKIP_SKILLS_INSTALL=1 opt out entirely
14
- * pnpm skills:install force the interactive install
12
+ * Wiring:
13
+ * - `postinstall` (auto on `pnpm install`): only acts in an interactive
14
+ * terminal; in CI/non-TTY it prints how to install and exits 0 (never hangs).
15
+ * - `pnpm skills:install` → picker (TUI) on a terminal, else installs all.
16
+ * - `pnpm skills:install --yes` → always non-interactive: install all skills.
17
+ * - `pnpm skills:update` → update installed skills to the latest.
18
+ *
19
+ * Flags / env:
20
+ * --update / -u update installed skills (runs `skills update -y`)
21
+ * --yes / -y non-interactive install of all bundled skills
22
+ * --agent <name> | --agent=<name> install for one agent (e.g. claude-code)
23
+ * instead of all detected agents
24
+ * --project-dir=<path> install into another project root (e.g. the outer repo
25
+ * a scaffold is nested in); project-scoped there
26
+ * --global / -g install at user level (~/.<agent>/…), available
27
+ * everywhere; some agents don't support global
28
+ * SKILLS_AGENT / SKILLS_PROJECT_DIR / SKILLS_GLOBAL env forms (for the auto
29
+ * `postinstall` step, which can't take CLI args, and CI)
30
+ * --dry-run / SKILLS_DRY_RUN=1 print the command instead of running it
31
+ * SKIP_SKILLS_INSTALL=1 opt out of the postinstall auto-step
15
32
  */
16
33
  import { spawnSync } from "node:child_process";
34
+ import fs from "node:fs";
35
+ import path from "node:path";
17
36
  import process from "node:process";
18
37
 
19
- const forced = process.argv.includes("--force");
38
+ const BUNDLED =
39
+ "obsidian-arrow-sandbox, arrow-js-obsidian-templates, arrow-js-obsidian-patterns, arrow-js-obsidian-porting";
40
+
41
+ const has = (flag) => process.argv.includes(flag);
42
+ // Accepts both `--flag value` and `--flag=value`.
43
+ const flagValue = (flag) => {
44
+ const eq = process.argv.find((a) => a.startsWith(`${flag}=`));
45
+ if (eq) {
46
+ return eq.slice(flag.length + 1);
47
+ }
48
+ const i = process.argv.indexOf(flag);
49
+ return i >= 0 ? process.argv[i + 1] : undefined;
50
+ };
51
+
52
+ /** Nearest ancestor *above* `dir` that is a git repo, or null. Used to warn
53
+ * when this project is nested inside another repo (skills install per-project,
54
+ * relative to cwd — not the outer root). */
55
+ function outerRepoAbove(dir) {
56
+ let current = path.dirname(path.resolve(dir));
57
+ const fsRoot = path.parse(current).root;
58
+ while (current && current !== fsRoot) {
59
+ if (fs.existsSync(path.join(current, ".git"))) {
60
+ return current;
61
+ }
62
+ current = path.dirname(current);
63
+ }
64
+ return null;
65
+ }
66
+
67
+ const forced = has("--force"); // set by `pnpm skills:install`
68
+ const update = has("--update") || has("-u");
69
+ const yes = has("--yes") || has("-y");
70
+ const global = has("--global") || has("-g") || process.env.SKILLS_GLOBAL === "1";
71
+ const agent = flagValue("--agent") || process.env.SKILLS_AGENT;
72
+ const projectDir = flagValue("--project-dir") || process.env.SKILLS_PROJECT_DIR;
73
+ const dryRun = has("--dry-run") || process.env.SKILLS_DRY_RUN === "1";
20
74
  const isCI = Boolean(process.env.CI);
21
75
  const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
22
76
  const optedOut = process.env.SKIP_SKILLS_INSTALL === "1";
23
77
 
24
- if (!forced && (isCI || optedOut || !interactive)) {
25
- console.log(
26
- "[skills] Skipping interactive skill install (non-interactive/CI/opt-out).\n Run `pnpm skills:install` to install the bundled skills via the npx skills TUI."
27
- );
28
- process.exit(0);
78
+ // `skills add` installs project-scope relative to cwd. To install into another
79
+ // root (e.g. the outer repo a scaffold is nested in), run the CLI there while
80
+ // sourcing the skills from THIS folder by absolute path.
81
+ const skillsSource = projectDir ? path.resolve(".") : ".";
82
+ const targetCwd = projectDir ? path.resolve(projectDir) : process.cwd();
83
+
84
+ if (projectDir && !fs.existsSync(targetCwd)) {
85
+ console.error(`[skills] --project-dir not found: ${targetCwd}`);
86
+ process.exit(1);
29
87
  }
30
88
 
31
- console.log("[skills] Launching `npx skills add .` — pick the skills to install in the TUI…\n");
89
+ function run(args, cwd = process.cwd()) {
90
+ const where = cwd === process.cwd() ? "" : ` (in ${cwd})`;
91
+ const pretty = ["npx", "skills", ...args].join(" ");
92
+ if (dryRun) {
93
+ console.log(`[skills] (dry-run) would run: ${pretty}${where}`);
94
+ process.exit(0);
95
+ }
96
+ console.log(`[skills] ${pretty}${where}`);
97
+ const result = spawnSync("npx", ["--yes", "skills", ...args], {
98
+ cwd,
99
+ stdio: "inherit",
100
+ shell: process.platform === "win32",
101
+ });
102
+ if (result.error) {
103
+ console.error(
104
+ `[skills] could not run npx skills (${result.error.message}). Install manually: npx skills add . --all --yes`
105
+ );
106
+ process.exit(0); // never fail an install over an optional convenience step
107
+ }
108
+ process.exit(result.status ?? 0);
109
+ }
32
110
 
33
- // `--yes` only auto-confirms npx fetching the `skills` package; the skills CLI's
34
- // own selection TUI still shows (we deliberately do not pass skills' `-y`).
35
- const result = spawnSync("npx", ["--yes", "skills", "add", "."], {
36
- stdio: "inherit",
37
- shell: process.platform === "win32",
38
- });
111
+ if (optedOut && !update && !forced) {
112
+ console.log("[skills] SKIP_SKILLS_INSTALL=1 skipping skill install.");
113
+ process.exit(0);
114
+ }
115
+
116
+ // Update an existing setup to the latest (works in any context).
117
+ if (update) {
118
+ console.log("[skills] Updating installed skills to the latest…");
119
+ run(["update", "-y"]);
120
+ }
39
121
 
40
- if (result.error) {
41
- console.error(
42
- `[skills] Could not run npx skills (${result.error.message}).\n Install manually: npx skills add .`
122
+ // Conservative postinstall: only auto-run when a human is at the terminal so
123
+ // `pnpm install` in CI never hangs or installs things unprompted.
124
+ if (!forced && (isCI || !interactive)) {
125
+ console.log(
126
+ `[skills] Bundled skills: ${BUNDLED}\n Install: pnpm skills:install (interactive picker on a terminal)\n pnpm skills:install --yes (non-interactive — installs all)\n Update: pnpm skills:update`
43
127
  );
44
- process.exit(0); // never fail an install over an optional convenience step
128
+ process.exit(0);
129
+ }
130
+
131
+ // Non-interactive (CI/agent/no TTY, or --yes, or an agent/global target): install
132
+ // ALL bundled skills with no prompts. Target one agent if asked, else all agents.
133
+ if (!interactive || yes || agent || global || projectDir) {
134
+ console.log(`[skills] Installing all bundled skills non-interactively: ${BUNDLED}`);
135
+ const outer = global || projectDir ? null : outerRepoAbove(process.cwd());
136
+ if (outer) {
137
+ console.log(
138
+ `[skills] note: this folder is nested inside ${outer}. Skills install here, scoped to THIS project. To install at the outer repo instead, re-run with --project-dir=${outer} (or --global for user-level).`
139
+ );
140
+ }
141
+ // `--all` is shorthand for `-s * -a * -y`; to target one agent we spell out
142
+ // all-skills + that agent explicitly. `skillsSource` is absolute when
143
+ // --project-dir redirects cwd elsewhere.
144
+ const args = agent
145
+ ? ["add", skillsSource, "-s", "*", "-a", agent, "-y"]
146
+ : ["add", skillsSource, "--all"];
147
+ if (global) {
148
+ args.push("--global");
149
+ }
150
+ run(args, targetCwd);
45
151
  }
46
152
 
47
- process.exit(result.status ?? 0);
153
+ // Interactive terminal: let the user pick in the TUI.
154
+ console.log(`[skills] Opening the skills picker. Bundled: ${BUNDLED}`);
155
+ run(["add", "."]);
@@ -0,0 +1,63 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Canonical source hashing for sandbox -> plugin porting parity.
5
+ *
6
+ * The sandbox is the source of truth for a component; the plugin gets a
7
+ * near-verbatim copy. This hashes the component's *portable body* so the only
8
+ * thing that can change the hash is meaningful drift in the component — not the
9
+ * bits that legitimately differ on the way into a plugin.
10
+ *
11
+ * Canonical form (define it so both sides agree by construction):
12
+ * 1. Drop `import …;` statements (single- or multi-line). Imports are the main
13
+ * legit delta: the sandbox imports stub data / no obsidian; the plugin
14
+ * imports the obsidian API / real data wiring. The component body is what
15
+ * must match.
16
+ * 2. Normalize line endings to \n, strip trailing whitespace per line, collapse
17
+ * runs of blank lines to one, trim leading/trailing blank lines.
18
+ * 3. SHA-256 the result.
19
+ *
20
+ * Note: comments are intentionally kept (they're part of the component; the
21
+ * sandbox is the source of truth for them too). To keep parity clean, write
22
+ * portable components that take data via props/getters so the body is identical
23
+ * across sandbox and plugin and only the mount site differs.
24
+ */
25
+
26
+ // Matches a whole `import …;` statement. `[^;]*` crosses newlines (the negated
27
+ // class matches \n) so multi-line imports are captured up to the first semicolon.
28
+ const IMPORT_STATEMENT = /^[ \t]*import\b[^;]*;[ \t]*$/gm;
29
+
30
+ export function canonicalizeSource(text) {
31
+ const withoutImports = text.replace(IMPORT_STATEMENT, "");
32
+ const lines = withoutImports
33
+ .replace(/\r\n/g, "\n")
34
+ .split("\n")
35
+ .map((line) => line.replace(/[ \t]+$/, ""));
36
+
37
+ // Collapse runs of blank lines to a single blank line.
38
+ const collapsed = [];
39
+ let lastBlank = false;
40
+ for (const line of lines) {
41
+ const blank = line === "";
42
+ if (blank && lastBlank) {
43
+ continue;
44
+ }
45
+ collapsed.push(line);
46
+ lastBlank = blank;
47
+ }
48
+
49
+ // Trim leading/trailing blank lines.
50
+ while (collapsed.length > 0 && collapsed[0] === "") {
51
+ collapsed.shift();
52
+ }
53
+ while (collapsed.length > 0 && collapsed[collapsed.length - 1] === "") {
54
+ collapsed.pop();
55
+ }
56
+
57
+ return `${collapsed.join("\n")}\n`;
58
+ }
59
+
60
+ /** SHA-256 hex of the canonical source. */
61
+ export function hashSource(text) {
62
+ return createHash("sha256").update(canonicalizeSource(text), "utf8").digest("hex");
63
+ }
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: arrow-js-obsidian-porting
3
+ description: Use when porting an Arrow component from the obsidian-arrow-sandbox into the real Obsidian plugin, or wiring a check that the plugin copy hasn't drifted from the sandbox source. Covers the content-addressed porting-parity tool (scripts/component-hash.mjs), the canonical-form rules, a port-parity manifest, and a husky/CI pre-commit hook. The sandbox is the source of truth; the plugin copy must match.
4
+ ---
5
+
6
+ # Porting parity (sandbox → Obsidian plugin)
7
+
8
+ The sandbox is the **source of truth** for a component; the plugin gets a
9
+ near-verbatim copy. The risk is **drift** — someone hand-edits the plugin copy,
10
+ or a port silently diverges. This makes components **content-addressed**: a
11
+ canonical hash of the component's portable body must match on both sides, the
12
+ same idea as "never hand-edit generated artifacts."
13
+
14
+ ## Write portable components
15
+
16
+ So the body is byte-identical across sandbox and plugin and only the *mount site*
17
+ differs:
18
+
19
+ - Take data via **props / getters**, not hard-wired sources. The sandbox passes a
20
+ stub (`loadStatus()`); the plugin passes the real source (rpc) — but that wiring
21
+ lives at the **call site** (`main.ts` / `ItemView.onOpen()`), not in the
22
+ component body.
23
+ - Use Obsidian classes + `var(--…)` tokens (already the rule). Keep
24
+ `obsidian`-API calls (`setIcon`, …) out of the component body where possible.
25
+
26
+ ## The tool: `scripts/component-hash.mjs`
27
+
28
+ Dependency-free Node; canonicalizer in `scripts/lib/canonical-source.mjs`.
29
+
30
+ ```sh
31
+ node scripts/component-hash.mjs <file> # print canonical hash
32
+ node scripts/component-hash.mjs --verify <sandbox> <plugin> # PARITY OK | DRIFT (exit 0/1)
33
+ node scripts/component-hash.mjs --check port-parity.json # check all; exit 1 on drift
34
+ node scripts/component-hash.mjs --check port-parity.json --update # re-bless baselines
35
+ ```
36
+
37
+ **Canonical form** (define it so both sides agree by construction):
38
+ - `import …;` statements are stripped (single- and multi-line) — imports are the
39
+ main legit delta (stub vs real data, the `obsidian` API).
40
+ - Line endings → `\n`, trailing whitespace trimmed, blank-line runs collapsed,
41
+ leading/trailing blanks trimmed.
42
+ - Comments are **kept** (they're part of the component; the sandbox owns them too).
43
+ - SHA-256 of the result.
44
+
45
+ So two copies that differ only in imports/formatting hash equal; any change to
46
+ the body/template/comments shows as drift.
47
+
48
+ ## Wiring the check in the plugin
49
+
50
+ The hook lives in the **plugin** repo (that's where ported copies drift). Copy the
51
+ two dependency-free files in (`scripts/lib/canonical-source.mjs`,
52
+ `scripts/component-hash.mjs`) and add a manifest:
53
+
54
+ ```jsonc
55
+ // port-parity.json — paths resolve relative to THIS file
56
+ {
57
+ "entries": [
58
+ {
59
+ "plugin": "src/chat/arrow/SettingsPanel.ts",
60
+ "sandbox": "../../arrow-ui/obsidian-arrow-sandbox/src/components/SettingsPanel.ts", // optional
61
+ "hash": "<sha256>" // fallback when sandbox isn't checked out
62
+ }
63
+ ]
64
+ }
65
+ ```
66
+
67
+ - If the `sandbox` path exists (sibling checkout), the check compares **plugin
68
+ copy ↔ live sandbox source** — catches drift in both directions.
69
+ - If it doesn't, it falls back to the recorded `hash` (catches hand-edits to the
70
+ plugin copy). Record/refresh it with `--update` after an intentional re-port.
71
+
72
+ husky `pre-commit` (and CI):
73
+
74
+ ```sh
75
+ node scripts/component-hash.mjs --check port-parity.json
76
+ ```
77
+
78
+ On drift the commit fails: *edit the sandbox component and re-port* (don't
79
+ hand-edit the copy); use `--update` only to intentionally re-bless.
80
+
81
+ ## What this does NOT do (by design)
82
+
83
+ - **No runtime component-DOM hashing in Obsidian.** Source parity already
84
+ guarantees same code → same Arrow DOM, and the sandbox vs real Obsidian DOM
85
+ *intentionally* differ (icon stub vs `setIcon`, `MarkdownRenderer` nodes, live
86
+ theme), so a runtime markup hash would be tautological *and* full of false
87
+ mismatches. The real runtime risk is visual/CSS — verify that by **loading in
88
+ Obsidian and looking**, not with a hash.
89
+ - **Planned (not built): a styling-freshness check.** A small helper that hashes
90
+ the *live Obsidian token set* and compares it to the sandbox's pulled `app.css`
91
+ snapshot, to flag "re-run `pnpm pull-css`." That's the one runtime hash worth
92
+ having — environment parity, not component parity. Add it when needed.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: arrow-js-obsidian-templates
3
- description: Use when writing @arrow-js/core (v1.0.6) html`` templatesthe reactive-vs-static rule, full-value attribute binding (returning false removes the attribute), .property and @event binding, keyed lists, async component(fn, { fallback }) wrapped in boundary(), and the footguns: no literal HTML comments inside templates and no partial attribute values (both throw "Invalid HTML position" at render), plus @event handlers must type the param as Event not a narrowed subtype like MouseEvent (TS2345).
3
+ description: Use when writing @arrow-js/core (v1.0.6) html template literals reactive vs static (wrap reactive reads in arrow functions), full-value attribute binding where returning false removes the attribute, .property and @event binding, keyed lists, and async component(fn, { fallback }) via boundary(); plus the footguns no literal HTML comments and no partial attribute values (both throw Invalid HTML position at render), and @event handlers must type the param as Event not a narrowed subtype like MouseEvent (TS2345).
4
4
  ---
5
5
 
6
6
  # Arrow.js Templates (v1.0.6)
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: obsidian-arrow-maintenance
3
+ description: Use when updating or maintaining an EXISTING scaffolded obsidian-arrow project — refresh the managed tooling with create-obsidian-arrow update, update installed agent skills with pnpm skills:update, fix skills scoping when the project is nested inside another repo (--project-dir / --global), and re-pull Obsidian styling. Only tooling/skills/docs are refreshed; your src is preserved.
4
+ ---
5
+
6
+ # Maintaining an existing Obsidian Arrow project
7
+
8
+ How to bring an already-scaffolded project up to date. The scaffolder is
9
+ create-only (it refuses a non-empty dir), so updates split into three tracks:
10
+ **tooling files**, **agent skills**, and **styling**. None of these touch your
11
+ `src/`.
12
+
13
+ ## 1. Refresh the tooling (scripts, skills files, docs, CI, config)
14
+
15
+ ```sh
16
+ npx create-obsidian-arrow update # in the project root (or: update <dir>)
17
+ npx create-obsidian-arrow update --dry-run # preview what would change first
18
+ ```
19
+
20
+ Refreshes the **managed** files from the latest template — `scripts/`, `skills/`,
21
+ `docs/`, `.github/`, `.husky/`, `biome.json`, `AGENTS.md`, `CLAUDE.md` — and
22
+ **merges** `package.json` scripts + any missing deps. It **never** touches `src/`,
23
+ `public/`, `index.html`, `vite.config.ts`, `tsconfig.json`, or `.gitignore`. After
24
+ it runs: `pnpm install && pnpm check`.
25
+
26
+ ## 2. Update the installed agent skills
27
+
28
+ ```sh
29
+ pnpm skills:update # = npx skills update -y (updates installed skills in place)
30
+ ```
31
+
32
+ Or reinstall the latest straight from the published repo (works from anywhere,
33
+ even if the project predates the skill scripts):
34
+
35
+ ```sh
36
+ npx skills add kylebrodeur/obsidian-arrow-sandbox --all --yes
37
+ ```
38
+
39
+ ## 3. Nested inside another repo? Fix skills scoping
40
+
41
+ The `skills` CLI installs project-scope **relative to cwd**. If this project sits
42
+ inside a larger repo and your agent runs from the **outer** repo, skills installed
43
+ here won't be found. Install them where the agent looks:
44
+
45
+ ```sh
46
+ pnpm skills:install --yes --project-dir=<outer-repo> # project-scoped at the outer root
47
+ pnpm skills:install --yes --global # or user-level (all projects)
48
+ ```
49
+
50
+ (`SKILLS_PROJECT_DIR` / `SKILLS_GLOBAL` env forms drive the auto `postinstall`
51
+ step, which takes no CLI args.)
52
+
53
+ ## 4. Re-pull Obsidian styling after Obsidian updates
54
+
55
+ `public/app.css` is a local snapshot (git-ignored). If Obsidian updated or the
56
+ sandbox renders stale, refresh it:
57
+
58
+ ```sh
59
+ pnpm pull-css
60
+ ```
61
+
62
+ ## 5. Re-check porting parity
63
+
64
+ After updating, re-run the porting-parity check so plugin copies still match the
65
+ sandbox source (see the **arrow-js-obsidian-porting** skill):
66
+
67
+ ```sh
68
+ node scripts/component-hash.mjs --check port-parity.json
69
+ ```
@@ -34,6 +34,19 @@ elsewhere pass `--path <obsidian.asar|app.css>` or set `OBSIDIAN_ASAR=<path>`.
34
34
  `public/app.css` is **git-ignored** (Obsidian's proprietary CSS — not
35
35
  redistributed), so run `pnpm pull-css` once before `pnpm dev`.
36
36
 
37
+ ## Install the bundled skills
38
+
39
+ ```sh
40
+ pnpm skills:install --yes # non-interactive: install ALL bundled skills (agents/CI)
41
+ pnpm skills:install # interactive picker on a terminal
42
+ pnpm skills:update # update an already-installed setup
43
+ ```
44
+
45
+ Scope flags: `--agent <name>`, `--project-dir=<path>` (install into another repo
46
+ root — use this when the project is **nested** inside a larger repo), `--global`.
47
+ To update an existing project's tooling later, see the **obsidian-arrow-maintenance**
48
+ skill (`npx create-obsidian-arrow update`).
49
+
37
50
  ## Build a component
38
51
 
39
52
  Add `src/components/MyThing.ts` exporting an Arrow `component()`, then mount it
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { canonicalizeSource, hashSource } from "../scripts/lib/canonical-source.mjs";
4
+
5
+ /**
6
+ * Porting-parity canonicalization. The hash must ignore the bits that
7
+ * legitimately differ when a component is ported sandbox -> plugin (imports,
8
+ * whitespace) and flag everything else (the component body/template).
9
+ */
10
+
11
+ const SANDBOX = `import { component, html } from "@arrow-js/core";
12
+ import { loadStatus } from "../data/loadStatus";
13
+
14
+ export const Card = component(() => html\`<div class="setting-item">\${() => loadStatus()}</div>\`);
15
+ `;
16
+
17
+ // Same body, plugin-flavored imports (obsidian + real data wiring).
18
+ const PLUGIN = `import { component, html } from "@arrow-js/core";
19
+ import { loadStatus } from "./rpc";
20
+ import { setIcon } from "obsidian";
21
+
22
+ export const Card = component(() => html\`<div class="setting-item">\${() => loadStatus()}</div>\`);
23
+ `;
24
+
25
+ test("imports are stripped — same body hashes equal across sandbox and plugin", () => {
26
+ assert.equal(hashSource(SANDBOX), hashSource(PLUGIN));
27
+ });
28
+
29
+ test("a body change is detected as drift", () => {
30
+ const drifted = PLUGIN.replace("setting-item", "setting-item is-hacked");
31
+ assert.notEqual(hashSource(SANDBOX), hashSource(drifted));
32
+ });
33
+
34
+ test("whitespace and blank-line differences are normalized away", () => {
35
+ const messy = `${SANDBOX.replace(/\n/g, "\n\n")} \n`;
36
+ assert.equal(hashSource(SANDBOX), hashSource(messy));
37
+ });
38
+
39
+ test("multi-line imports are stripped", () => {
40
+ const multiline = `import {\n\tcomponent,\n\thtml,\n} from "@arrow-js/core";\n\nexport const X = 1;\n`;
41
+ const singleline = `import { component, html } from "@arrow-js/core";\n\nexport const X = 1;\n`;
42
+ assert.equal(hashSource(multiline), hashSource(singleline));
43
+ assert.match(canonicalizeSource(multiline), /^export const X = 1;\n$/);
44
+ });
45
+
46
+ test("hash is stable sha256 hex", () => {
47
+ assert.match(hashSource(SANDBOX), /^[0-9a-f]{64}$/);
48
+ });
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { test } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * The `skills` CLI parses each SKILL.md's YAML frontmatter; if it can't, the
9
+ * skill is silently skipped (not installed). An unquoted plain-scalar
10
+ * `description:` that contains `: ` (read as a nested key) or an unescaped
11
+ * double-quote breaks the parse — that's a real footgun (it skipped
12
+ * arrow-js-obsidian-templates until caught). These guards keep descriptions
13
+ * installable.
14
+ */
15
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
16
+ const skillsDir = path.join(root, "skills");
17
+
18
+ function skillFiles() {
19
+ if (!fs.existsSync(skillsDir)) {
20
+ return [];
21
+ }
22
+ return fs
23
+ .readdirSync(skillsDir, { withFileTypes: true })
24
+ .filter((entry) => entry.isDirectory())
25
+ .map((entry) => path.join(skillsDir, entry.name, "SKILL.md"))
26
+ .filter((file) => fs.existsSync(file));
27
+ }
28
+
29
+ function frontmatter(text) {
30
+ const match = text.match(/^---\n([\s\S]*?)\n---/);
31
+ return match ? match[1] : "";
32
+ }
33
+
34
+ test("every skill has name + description frontmatter", () => {
35
+ for (const file of skillFiles()) {
36
+ const fm = frontmatter(fs.readFileSync(file, "utf8"));
37
+ assert.match(fm, /^name:\s+\S/m, `${path.relative(root, file)}: missing name`);
38
+ assert.match(fm, /^description:\s+\S/m, `${path.relative(root, file)}: missing description`);
39
+ }
40
+ });
41
+
42
+ test("skill descriptions are YAML-safe so the skills CLI installs them", () => {
43
+ const offenders = [];
44
+ for (const file of skillFiles()) {
45
+ const fm = frontmatter(fs.readFileSync(file, "utf8"));
46
+ const desc = (fm.match(/^description:\s+(.*)$/m) ?? [])[1] ?? "";
47
+ const quoted = /^["']/.test(desc);
48
+ if (!quoted && (/: /.test(desc) || desc.includes('"'))) {
49
+ offenders.push(path.relative(root, file));
50
+ }
51
+ }
52
+ assert.deepEqual(
53
+ offenders,
54
+ [],
55
+ "unquoted description must not contain ': ' or a double-quote (breaks YAML; the skills CLI skips it)"
56
+ );
57
+ });