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.
- package/README.md +36 -2
- package/index.mjs +168 -48
- package/package.json +1 -1
- package/template/AGENTS.md +22 -2
- package/template/CLAUDE.md +4 -0
- package/template/README.md +37 -8
- package/template/_gitignore +4 -0
- package/template/docs/prompts/agent-setup.md +17 -6
- package/template/docs/workflow.md +85 -0
- package/template/package.json +1 -0
- package/template/scripts/component-hash.mjs +123 -0
- package/template/scripts/install-skills.mjs +136 -28
- package/template/scripts/lib/canonical-source.mjs +63 -0
- package/template/skills/arrow-js-obsidian-porting/SKILL.md +92 -0
- package/template/skills/arrow-js-obsidian-templates/SKILL.md +1 -1
- package/template/skills/obsidian-arrow-maintenance/SKILL.md +69 -0
- package/template/skills/obsidian-arrow-sandbox/SKILL.md +13 -0
- package/template/test/component-hash.test.mjs +48 -0
- package/template/test/skills-frontmatter.test.mjs +57 -0
- package/template/docs/superpowers/specs/2026-06-29-obsidian-arrow-sandbox-design.md +0 -206
|
@@ -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/*)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
* pnpm
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|