omakit 0.1.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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/bin/omakit +3 -0
- package/package.json +40 -0
- package/skills/omarchy-plugin-submit/SKILL.md +87 -0
- package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
- package/tests/parity/corpus.mjs +62 -0
- package/tests/parity/run.mjs +194 -0
- package/tools/marketplace/README.md +64 -0
- package/tools/marketplace/agent-control.mjs +72 -0
- package/tools/marketplace/banner.mjs +307 -0
- package/tools/marketplace/cli.mjs +283 -0
- package/tools/marketplace/completion.mjs +263 -0
- package/tools/marketplace/doctor.mjs +156 -0
- package/tools/marketplace/effect.mjs +115 -0
- package/tools/marketplace/form.mjs +209 -0
- package/tools/marketplace/github.mjs +206 -0
- package/tools/marketplace/issue.mjs +80 -0
- package/tools/marketplace/local-transport.mjs +162 -0
- package/tools/marketplace/parity-output.mjs +26 -0
- package/tools/marketplace/paths.mjs +10 -0
- package/tools/marketplace/pin.mjs +203 -0
- package/tools/marketplace/plugin.mjs +64 -0
- package/tools/marketplace/preflight.mjs +164 -0
- package/tools/marketplace/progress.mjs +96 -0
- package/tools/marketplace/registry.mjs +216 -0
- package/tools/marketplace/report.mjs +192 -0
- package/tools/marketplace/run-baseline.mjs +72 -0
- package/tools/marketplace/setup.mjs +136 -0
- package/tools/marketplace/style.mjs +443 -0
- package/tools/marketplace/submit.mjs +341 -0
- package/tools/marketplace/tree.mjs +50 -0
- package/tools/marketplace/upgrade.mjs +179 -0
- package/tools/marketplace/usage.mjs +191 -0
- package/tools/marketplace/verify.mjs +70 -0
- package/tools/marketplace/watch.mjs +262 -0
- package/tools/marketplace/yaml.mjs +164 -0
- package/tools/subject/resolve.mjs +124 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Parity: the pinned official baseline over the GitHub transport must equal the
|
|
2
|
+
// same code over Omakit's local Git transport, for real listed plugin
|
|
3
|
+
// repositories at their exact commits. Read-only: clones and GET requests only.
|
|
4
|
+
//
|
|
5
|
+
// The committed evidence records, per repository, a digest of each transport's
|
|
6
|
+
// comparable result rather than the results themselves. Two reasons. Equal
|
|
7
|
+
// digests are the whole parity claim, and anyone can recompute them from the
|
|
8
|
+
// repository, the commit and the pin. And findings about a specific third-party
|
|
9
|
+
// plugin are not this project's to publish: the aggregate rule distribution in
|
|
10
|
+
// the summary carries no repository names, and the per-repository rows carry no
|
|
11
|
+
// findings, no capabilities, no file paths and no source snippets.
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto"
|
|
14
|
+
import { execFileSync } from "node:child_process"
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
16
|
+
import { join, resolve } from "node:path"
|
|
17
|
+
import { runBaseline } from "../../tools/marketplace/run-baseline.mjs"
|
|
18
|
+
import { requirePin } from "../../tools/marketplace/pin.mjs"
|
|
19
|
+
import { parityCorpus, strataSizes } from "./corpus.mjs"
|
|
20
|
+
import { subjectSlug } from "../../tools/subject/resolve.mjs"
|
|
21
|
+
import { token } from "../../tools/marketplace/github.mjs"
|
|
22
|
+
import { omakitCacheDir } from "../../tools/marketplace/paths.mjs"
|
|
23
|
+
import { parityOutput } from "../../tools/marketplace/parity-output.mjs"
|
|
24
|
+
|
|
25
|
+
const repoRoot = resolve(process.env.OMAKIT_ROOT || process.cwd())
|
|
26
|
+
const pinDir = requirePin(repoRoot).dir
|
|
27
|
+
const cacheDir = omakitCacheDir("parity")
|
|
28
|
+
const output = parityOutput({ repoRoot, out: process.env.PARITY_OUT || null })
|
|
29
|
+
const count = Number(process.env.PARITY_COUNT || 30)
|
|
30
|
+
const offset = Number(process.env.PARITY_OFFSET || 0)
|
|
31
|
+
|
|
32
|
+
function shallowClone(repoUrl, commit) {
|
|
33
|
+
const dir = join(cacheDir, subjectSlug(repoUrl))
|
|
34
|
+
mkdirSync(dir, { recursive: true })
|
|
35
|
+
const run = (args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
|
36
|
+
try {
|
|
37
|
+
run(["rev-parse", commit + "^{commit}"])
|
|
38
|
+
return dir
|
|
39
|
+
} catch {
|
|
40
|
+
/* not fetched yet */
|
|
41
|
+
}
|
|
42
|
+
// Test for the clone's own .git instead of relying on an outer repository.
|
|
43
|
+
if (!existsSync(join(dir, ".git"))) {
|
|
44
|
+
execFileSync("git", ["init", "-q", dir], { encoding: "utf8" })
|
|
45
|
+
run(["remote", "add", "origin", repoUrl])
|
|
46
|
+
}
|
|
47
|
+
run(["fetch", "-q", "--depth", "1", "origin", commit])
|
|
48
|
+
return dir
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function comparable(result) {
|
|
52
|
+
return JSON.stringify({
|
|
53
|
+
outcome: result.outcome,
|
|
54
|
+
disposition: result.disposition,
|
|
55
|
+
blocksApproval: result.blocksApproval,
|
|
56
|
+
findings: result.findings,
|
|
57
|
+
capabilities: result.capabilities,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A digest of the comparable payload: proves equality without publishing it. */
|
|
62
|
+
function digest(result) {
|
|
63
|
+
return createHash("sha256").update(comparable(result)).digest("hex").slice(0, 32)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pinCommit(dir) {
|
|
67
|
+
try {
|
|
68
|
+
return execFileSync("git", ["-C", dir, "rev-parse", "HEAD"], { encoding: "utf8" }).trim()
|
|
69
|
+
} catch {
|
|
70
|
+
return "unknown"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const corpus = parityCorpus(pinDir, count, offset)
|
|
75
|
+
const rows = []
|
|
76
|
+
const ruleTotals = {}
|
|
77
|
+
const capabilityTotals = {}
|
|
78
|
+
let mismatches = 0
|
|
79
|
+
let failures = 0
|
|
80
|
+
|
|
81
|
+
for (const target of corpus) {
|
|
82
|
+
const row = {
|
|
83
|
+
repo: target.repo,
|
|
84
|
+
commit: target.commit,
|
|
85
|
+
type: target.type,
|
|
86
|
+
registryOutcome: target.registryOutcome,
|
|
87
|
+
registryOutcomeAtSameCommit: target.registryOutcomeCommit === target.commit,
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const dir = shallowClone(target.repo, target.commit)
|
|
91
|
+
const local = await runBaseline({
|
|
92
|
+
repoRoot,
|
|
93
|
+
repoUrl: target.repo,
|
|
94
|
+
commitSha: target.commit,
|
|
95
|
+
transport: "local",
|
|
96
|
+
repoDir: dir,
|
|
97
|
+
})
|
|
98
|
+
row.localOutcome = local.result.outcome
|
|
99
|
+
row.requests = local.adapter.requests
|
|
100
|
+
row.localDigest = digest(local.result)
|
|
101
|
+
// Rule and capability ids are counted in the summary, unattributed; they are
|
|
102
|
+
// deliberately not recorded against this repository.
|
|
103
|
+
for (const finding of local.result.findings || []) {
|
|
104
|
+
const id = finding.ruleId || finding.id
|
|
105
|
+
if (id) ruleTotals[id] = (ruleTotals[id] || 0) + 1
|
|
106
|
+
}
|
|
107
|
+
for (const capability of local.result.capabilities || []) {
|
|
108
|
+
if (capability.id) capabilityTotals[capability.id] = (capabilityTotals[capability.id] || 0) + 1
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const github = await runBaseline({
|
|
112
|
+
repoRoot,
|
|
113
|
+
repoUrl: target.repo,
|
|
114
|
+
commitSha: target.commit,
|
|
115
|
+
transport: "github",
|
|
116
|
+
token: token() ?? undefined,
|
|
117
|
+
})
|
|
118
|
+
row.githubOutcome = github.result.outcome
|
|
119
|
+
row.githubDigest = digest(github.result)
|
|
120
|
+
row.identical = row.localDigest === row.githubDigest
|
|
121
|
+
if (!row.identical) {
|
|
122
|
+
mismatches += 1
|
|
123
|
+
// A mismatch is the one case worth describing, and it is described as a
|
|
124
|
+
// difference between two transports of the same code, not as a finding
|
|
125
|
+
// about the plugin: which keys differ, never their contents.
|
|
126
|
+
const localPayload = JSON.parse(comparable(local.result))
|
|
127
|
+
const githubPayload = JSON.parse(comparable(github.result))
|
|
128
|
+
row.differingKeys = Object.keys(localPayload).filter(
|
|
129
|
+
(key) => JSON.stringify(localPayload[key]) !== JSON.stringify(githubPayload[key]),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
row.githubError = (error.code || "error") + ": " + error.message
|
|
134
|
+
failures += 1
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
row.localError = (error.code || "error") + ": " + error.message
|
|
138
|
+
failures += 1
|
|
139
|
+
}
|
|
140
|
+
rows.push(row)
|
|
141
|
+
console.log(
|
|
142
|
+
[
|
|
143
|
+
row.identical === true ? "ok " : row.identical === false ? "DIFF" : "?? ",
|
|
144
|
+
target.repo.replace("https://github.com/", "").padEnd(44),
|
|
145
|
+
"local=" + (row.localOutcome || row.localError || "-"),
|
|
146
|
+
"github=" + (row.githubOutcome || row.githubError || "-"),
|
|
147
|
+
"registry=" + (row.registryOutcome || "-"),
|
|
148
|
+
].join(" "),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function generatorIdentity() {
|
|
153
|
+
try {
|
|
154
|
+
return { omakitCommit: execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() }
|
|
155
|
+
} catch {
|
|
156
|
+
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
|
|
157
|
+
return { omakitPackage: `${pkg.name}@${pkg.version}` }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const summary = {
|
|
162
|
+
generator: generatorIdentity(),
|
|
163
|
+
pinnedMarketplaceCommit: pinCommit(pinDir),
|
|
164
|
+
corpusSize: corpus.length,
|
|
165
|
+
requestedCount: count,
|
|
166
|
+
offset,
|
|
167
|
+
strata: strataSizes(count),
|
|
168
|
+
outcomes: rows.reduce((acc, row) => {
|
|
169
|
+
const key = row.localOutcome || "error"
|
|
170
|
+
acc[key] = (acc[key] || 0) + 1
|
|
171
|
+
return acc
|
|
172
|
+
}, {}),
|
|
173
|
+
identical: rows.filter((row) => row.identical === true).length,
|
|
174
|
+
mismatches,
|
|
175
|
+
failures,
|
|
176
|
+
// Unattributed aggregates. No repository name is attached to any rule.
|
|
177
|
+
ruleTotals,
|
|
178
|
+
capabilityTotals,
|
|
179
|
+
publication: {
|
|
180
|
+
perRepositoryDetail: "omitted",
|
|
181
|
+
reason: "Findings about a specific third-party plugin are not published. Equality is evidenced by the sha256 digests below, which anyone can recompute from the repository, the commit and the pinned marketplace commit.",
|
|
182
|
+
digest: "sha256 of {outcome, disposition, blocksApproval, findings, capabilities}, first 32 hex characters",
|
|
183
|
+
},
|
|
184
|
+
rows,
|
|
185
|
+
}
|
|
186
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
187
|
+
const outputIsFile = process.env.PARITY_OUT_EXPLICIT === "1"
|
|
188
|
+
const outputDir = outputIsFile ? resolve(output, "..") : output
|
|
189
|
+
mkdirSync(outputDir, { recursive: true })
|
|
190
|
+
let outFile = outputIsFile ? output : join(outputDir, `${date}-local-vs-github.json`)
|
|
191
|
+
for (let n = 2; !outputIsFile && existsSync(outFile); n += 1) outFile = join(outputDir, `${date}-local-vs-github-${n}.json`)
|
|
192
|
+
writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n")
|
|
193
|
+
console.log("\nidentical " + summary.identical + "/" + corpus.length + ", mismatches " + mismatches + ", failures " + failures + " -> " + outFile)
|
|
194
|
+
process.exit(mismatches === 0 && failures === 0 ? 0 : 1)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Module map
|
|
2
|
+
|
|
3
|
+
Omakit never copies marketplace policy. It imports the official analysis from a
|
|
4
|
+
read-only checkout of the pinned marketplace commit and feeds it a snapshot of a
|
|
5
|
+
local commit through the transport seam the marketplace tests itself
|
|
6
|
+
(`resolveSecuritySnapshot(..., { fetchImpl })`).
|
|
7
|
+
|
|
8
|
+
| File | Purpose |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. |
|
|
11
|
+
| `local-transport.mjs` | Answers the four request shapes the official resolver makes, from a local clone at the exact commit. No network, no credentials, no writes. |
|
|
12
|
+
| `run-baseline.mjs` | Runs the pinned official baseline over either transport and reports the pin identity beside the result. |
|
|
13
|
+
| `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. |
|
|
14
|
+
| `preflight.mjs` | Translates that result into what it will cause on submission, using the pinned policy, and renders the marketplace's own report text with its attestation marker stripped and asserted absent. |
|
|
15
|
+
| `yaml.mjs` | A deliberately small YAML reader for the pinned issue form. Accepts that subset and throws on anything else. |
|
|
16
|
+
| `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. |
|
|
17
|
+
| `registry.mjs` | The plugin-id and repository universe, and the reserved namespace, read from the pinned registry, catalog and catalog builder. |
|
|
18
|
+
| `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
|
|
19
|
+
| `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
|
|
20
|
+
| `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
|
|
21
|
+
| `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
|
|
22
|
+
| `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. |
|
|
23
|
+
| `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
|
|
24
|
+
| `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
|
|
25
|
+
| `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. `docs/TUI.md` explains it. |
|
|
26
|
+
| `report.mjs` | Text rendering of submit, watch and doctor for the agent that runs this tool, and the person reading over its shoulder. |
|
|
27
|
+
| `usage.mjs` | The help text, as data. |
|
|
28
|
+
| `completion.mjs` | A completion script for bash, zsh or fish, derived from the help data and the pin's form: the subcommands and flags are read out of `COMMANDS`, the categories and tags out of the pinned submission form, and the script says which pin it came from. `setup` installs it for the shell in `$SHELL`, the one file this tool writes outside its own checkout. |
|
|
29
|
+
| `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
|
|
30
|
+
| `effect.mjs` | The one text effect: the wordmark through `ttfx` where it is drawn, with frozen arguments, a hard budget, no colour of its own, and nothing at all when `ttfx` is not there. |
|
|
31
|
+
| `progress.mjs` | The progress line, on stderr, only when a person is looking. |
|
|
32
|
+
| `cli.mjs` | The one entry point behind `bin/omakit`, and the one register every failure is reported in. |
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
omakit pin
|
|
36
|
+
omakit submit /path/to/plugin-repo --category Widgets --tags bar,quickshell
|
|
37
|
+
omakit submit https://github.com/owner/repo@<40-char sha> --category System --tags system
|
|
38
|
+
omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/4829
|
|
39
|
+
omakit verify /path/to/plugin-repo
|
|
40
|
+
omakit parity --count 30
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A local repository needs an `origin` on github.com for the official code to name
|
|
44
|
+
the repository; without one the baseline section records `transport: "none"` and
|
|
45
|
+
the reason. Reviewer targets are fetched read-only into
|
|
46
|
+
`.cache/subjects/<owner>__<repo>/` and never executed.
|
|
47
|
+
|
|
48
|
+
Metadata that cannot be known from a local clone (repository visibility, archived
|
|
49
|
+
and disabled state, tree truncation) is answered with the minimal valid value and
|
|
50
|
+
listed in `assumedByAdapter`. The marketplace rescans the public commit itself; a
|
|
51
|
+
local run is a preview, never an authority.
|
|
52
|
+
|
|
53
|
+
Parity is proven, not assumed: `omakit parity` compares both transports over real
|
|
54
|
+
listed repositories at their listing-validated commits, stratified on the outcome
|
|
55
|
+
the registry recorded so the corpus always contains repositories that are not
|
|
56
|
+
`passed`. Results land in `docs/evidence/parity/`, recording a digest of each
|
|
57
|
+
side rather than the findings themselves. The GitHub side uses whatever
|
|
58
|
+
credential `github.mjs` resolves (a `gh` login, and only that), read-only.
|
|
59
|
+
|
|
60
|
+
`parity` and `watch` are the only commands that reach the network, and they do it
|
|
61
|
+
with Node's built-in `fetch`, which does not read proxy environment variables by
|
|
62
|
+
default. Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` and
|
|
63
|
+
`verify` need no network at all beyond fetching a reviewer-mode subject, and
|
|
64
|
+
`tests/parity/offline.mjs` proves it.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The agent-control check.
|
|
2
|
+
//
|
|
3
|
+
// Measured reason (docs/MEASUREMENTS.md M3): 103 marketplace issues mention
|
|
4
|
+
// agent-control files. The maintainer treats an instruction file inside an
|
|
5
|
+
// installed plugin as a prompt-injection surface and blocks listing on it; in a
|
|
6
|
+
// 328-comment sample of his review writing, 24 comments are about exactly this.
|
|
7
|
+
// It is not detected by the marketplace's automated baseline, so an author
|
|
8
|
+
// learns about it only from a human review round, which is the most expensive
|
|
9
|
+
// round there is.
|
|
10
|
+
//
|
|
11
|
+
// This is an Omakit check, derived from public issue text, not a marketplace
|
|
12
|
+
// rule. The verdict field says so, and the wording never reuses the
|
|
13
|
+
// marketplace's outcome vocabulary.
|
|
14
|
+
|
|
15
|
+
import { isBlob } from "./tree.mjs"
|
|
16
|
+
|
|
17
|
+
// Names an agent reads as instructions when the plugin is installed.
|
|
18
|
+
export const AGENT_CONTROL_FILES = Object.freeze(["AGENTS.md", "CLAUDE.md", "SKILL.md", ".mcp.json"])
|
|
19
|
+
export const AGENT_CONTROL_DIRECTORIES = Object.freeze([".claude", ".codex"])
|
|
20
|
+
export const INSTRUCTION_DIRECTORY = "skills"
|
|
21
|
+
export const INSTRUCTION_EXTENSIONS = Object.freeze([".md", ".markdown", ".mdc"])
|
|
22
|
+
|
|
23
|
+
export const REMEDY = Object.freeze([
|
|
24
|
+
"Move the guidance to a non-agent filename such as DEVELOPMENT.md.",
|
|
25
|
+
"Untrack the originals (git rm --cached) so they leave the installable tree, and add them to .gitignore.",
|
|
26
|
+
"Keep a recursive release check so they cannot return.",
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
function basename(path) {
|
|
30
|
+
const slash = path.lastIndexOf("/")
|
|
31
|
+
return slash === -1 ? path : path.slice(slash + 1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function segments(path) {
|
|
35
|
+
return path.split("/")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find every agent-control file anywhere in the tree.
|
|
40
|
+
*
|
|
41
|
+
* Matching is case-insensitive on the basename: agents read `agents.md` as
|
|
42
|
+
* readily as `AGENTS.md`, so a lowercase copy is the same injection surface.
|
|
43
|
+
*
|
|
44
|
+
* @param {Array<{ path: string, mode: string, type: string }>} entries
|
|
45
|
+
* @returns {Array<{ path: string, reason: string }>}
|
|
46
|
+
*/
|
|
47
|
+
export function findAgentControl(entries) {
|
|
48
|
+
const files = new Set(AGENT_CONTROL_FILES.map((name) => name.toLowerCase()))
|
|
49
|
+
const directories = new Set(AGENT_CONTROL_DIRECTORIES.map((name) => name.toLowerCase()))
|
|
50
|
+
const hits = []
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (!isBlob(entry)) continue
|
|
53
|
+
const parts = segments(entry.path)
|
|
54
|
+
const name = basename(entry.path).toLowerCase()
|
|
55
|
+
const directory = parts.slice(0, -1).find((part) => directories.has(part.toLowerCase()))
|
|
56
|
+
if (directory) {
|
|
57
|
+
hits.push({ path: entry.path, reason: `inside an agent-control directory (${directory}/)` })
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (files.has(name)) {
|
|
61
|
+
hits.push({ path: entry.path, reason: `agent-control file (${basename(entry.path)})` })
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
const inSkills = parts.slice(0, -1).some((part) => part.toLowerCase() === INSTRUCTION_DIRECTORY)
|
|
65
|
+
if (inSkills && INSTRUCTION_EXTENSIONS.some((extension) => name.endsWith(extension))) {
|
|
66
|
+
hits.push({ path: entry.path, reason: `instruction file inside a ${INSTRUCTION_DIRECTORY}/ directory` })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Codepoint order, not locale order: the output is read and compared by
|
|
70
|
+
// agents and tests, so it must not depend on the machine's locale data.
|
|
71
|
+
return hits.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
72
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// The wordmark, and the one place decoration is allowed.
|
|
2
|
+
//
|
|
3
|
+
// It is drawn in two places: a bare `omakit`, the front door, where the short
|
|
4
|
+
// list under it fits on any screen; and `omakit setup`, a first run already
|
|
5
|
+
// spending seconds fetching the pin. Not on `omakit help`, whose 53 lines
|
|
6
|
+
// scroll it off the top before anyone has read it, and not on `doctor`, which
|
|
7
|
+
// is run to read a report. Never in `submit`, `watch` or `verify` output,
|
|
8
|
+
// because that output gets pasted into issues and read by agents, and a
|
|
9
|
+
// banner there costs a reader lines and costs a submission credibility.
|
|
10
|
+
//
|
|
11
|
+
// One more gate. It draws only when stdout is a terminal, so `omakit | less`
|
|
12
|
+
// and `omakit setup | tee` stay plain text; there is no switch of omakit's own, because a pipe
|
|
13
|
+
// and TERM=dumb are the terminal's way of saying the same thing. NO_COLOR does
|
|
14
|
+
// what it says and no more: the wordmark is still drawn, in the terminal's own
|
|
15
|
+
// foreground, because a person who turned colour off did not ask for a
|
|
16
|
+
// different program. The same words arrive either way; a piped run gets them
|
|
17
|
+
// without the wordmark.
|
|
18
|
+
//
|
|
19
|
+
// The animation is on a budget: the whole scan is MOTION.bannerBudgetMs, about
|
|
20
|
+
// a quarter of a second, so the first line under it is on the screen before a
|
|
21
|
+
// person has finished looking at the wordmark. The first version of this took
|
|
22
|
+
// 1.4 seconds. The schedule below is derived from the budget rather than from
|
|
23
|
+
// taste, so the wordmark can grow a letter without the scan growing a delay.
|
|
24
|
+
//
|
|
25
|
+
// How `oma` and `kit` are told apart, and why it is not by colour.
|
|
26
|
+
//
|
|
27
|
+
// The measured problem: on Omarchy's Matte Black theme every ANSI hue resolves
|
|
28
|
+
// to nearly the same grey, so a wordmark whose two halves differ in tint
|
|
29
|
+
// renders as one flat word, and bold does not rescue it, because a block glyph
|
|
30
|
+
// has no stroke for a bold face to thicken and most terminals no longer
|
|
31
|
+
// brighten bold text. What a monochrome terminal does still draw is ink. So
|
|
32
|
+
// the two halves differ in density: `oma`, the ecosystem's prefix, is drawn in
|
|
33
|
+
// the dark shade (▓), and `kit`, this tool's own name, in the full block (█).
|
|
34
|
+
// The prefix recedes into texture and the name stands solid, on any theme, and
|
|
35
|
+
// under NO_COLOR, where there is no escape sequence at all. The tint is still
|
|
36
|
+
// applied on top when colour is on, cyan on the prefix and the foreground on
|
|
37
|
+
// the name, so a colour theme gets both cues and a monochrome one gets the one
|
|
38
|
+
// it can show.
|
|
39
|
+
//
|
|
40
|
+
// The trade-off is the shade glyph itself: ▓ is a pattern, and a pattern only
|
|
41
|
+
// reads as a letter when adjacent cells tile without a seam. Every monospace
|
|
42
|
+
// font ships it as a tiling glyph, and docs/media/render.py measures the
|
|
43
|
+
// rendered wordmark and refuses to produce a GIF in which the shaded rows do
|
|
44
|
+
// not join.
|
|
45
|
+
//
|
|
46
|
+
// The animation is the tool's own motif rather than an ornament: the same
|
|
47
|
+
// scanner that sweeps the progress line during a baseline run sweeps across the
|
|
48
|
+
// name. Nothing here imitates anyone else's logo, character or product; the
|
|
49
|
+
// letters come from the small font below, so renaming the tool is a change to
|
|
50
|
+
// one string and not a redrawing job.
|
|
51
|
+
|
|
52
|
+
import { code, colourEnabled, DENSITY, motionEnabled, MOTION, rule as floorRule } from "./style.mjs"
|
|
53
|
+
import { effectAvailable, playEffect } from "./effect.mjs"
|
|
54
|
+
|
|
55
|
+
const ESC = "\u001b["
|
|
56
|
+
const RESET = `${ESC}0m`
|
|
57
|
+
// The tints, applied only when colour is on. The head of the scanner is the
|
|
58
|
+
// one bright thing, the typeable role's tint in bold, the same tint the progress
|
|
59
|
+
// line's head moves; the prefix takes that tint too; the name keeps the
|
|
60
|
+
// terminal's foreground.
|
|
61
|
+
const TINT = Object.freeze({
|
|
62
|
+
head: `${ESC}${code("typeable.bold")}m`,
|
|
63
|
+
prefix: `${ESC}${code("typeable")}m`,
|
|
64
|
+
suffix: `${ESC}${code("prose")}m`,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// A five-row pixel font, "#" lit and " " blank, one blank column between
|
|
68
|
+
// letters. Only the letters the name needs are defined; adding one is adding one
|
|
69
|
+
// entry of five equal-length strings.
|
|
70
|
+
export const GLYPHS = Object.freeze({
|
|
71
|
+
a: [" ## ", "# #", "####", "# #", "# #"],
|
|
72
|
+
c: [" ###", "# ", "# ", "# ", " ###"],
|
|
73
|
+
// Three wide with bars, not a single stroke: a one-column i is ambiguous next
|
|
74
|
+
// to a k, and the wordmark reads as capitals anyway.
|
|
75
|
+
i: ["###", " # ", " # ", " # ", "###"],
|
|
76
|
+
k: ["# #", "# # ", "## ", "# # ", "# #"],
|
|
77
|
+
m: ["# #", "## ##", "# # #", "# #", "# #"],
|
|
78
|
+
n: ["# #", "## #", "# ##", "# #", "# #"],
|
|
79
|
+
o: [" ## ", "# #", "# #", "# #", " ## "],
|
|
80
|
+
s: [" ###", "# ", " ## ", " #", "### "],
|
|
81
|
+
t: ["###", " # ", " # ", " # ", " # "],
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
export const GLYPH_ROWS = 5
|
|
85
|
+
|
|
86
|
+
/** How many leading letters are the prefix: drawn in the shade, and tinted cyan when colour is on. */
|
|
87
|
+
export const PREFIX_LETTERS = 3
|
|
88
|
+
|
|
89
|
+
/** The two densities: the prefix is texture, the name is solid. */
|
|
90
|
+
export const INK = Object.freeze({ prefix: DENSITY.dark, suffix: DENSITY.full, head: DENSITY.full })
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lay a word out as five rows plus the column span of each letter.
|
|
94
|
+
* Throws on a letter the font does not have, rather than dropping it silently.
|
|
95
|
+
*/
|
|
96
|
+
export function wordmarkLayout(word) {
|
|
97
|
+
const letters = [...String(word).toLowerCase()]
|
|
98
|
+
const missing = letters.filter((letter) => !GLYPHS[letter])
|
|
99
|
+
if (missing.length) {
|
|
100
|
+
throw new Error(`banner: no glyph for ${[...new Set(missing)].join(", ")}; add it to GLYPHS`)
|
|
101
|
+
}
|
|
102
|
+
const rows = []
|
|
103
|
+
for (let row = 0; row < GLYPH_ROWS; row += 1) {
|
|
104
|
+
rows.push(letters.map((letter) => GLYPHS[letter][row]).join(" "))
|
|
105
|
+
}
|
|
106
|
+
const spans = []
|
|
107
|
+
let column = 0
|
|
108
|
+
for (const [index, letter] of letters.entries()) {
|
|
109
|
+
const width = GLYPHS[letter][0].length
|
|
110
|
+
spans.push({ letter, index, from: column, to: column + width - 1 })
|
|
111
|
+
column += width + 1
|
|
112
|
+
}
|
|
113
|
+
return { rows, spans, width: rows[0].length }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Kept for the simple case: just the five rows. */
|
|
117
|
+
export function wordmarkRows(word) {
|
|
118
|
+
return wordmarkLayout(word).rows
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The whole animation, in milliseconds, stated once in style.mjs beside every
|
|
123
|
+
* other budget. Not a taste parameter: `help` exists to put text on the
|
|
124
|
+
* screen, so the scan has to be over before it is in the way. A quarter of a
|
|
125
|
+
* second is about one glance.
|
|
126
|
+
*/
|
|
127
|
+
export const BUDGET_MS = MOTION.bannerBudgetMs
|
|
128
|
+
|
|
129
|
+
// How many columns the head jumps per frame. Two for the reveal keeps the sweep
|
|
130
|
+
// continuous, because the head is two columns wide; three for the return pass
|
|
131
|
+
// is a highlight travelling over letters that are already drawn, where a gap
|
|
132
|
+
// costs nothing.
|
|
133
|
+
const REVEAL_STRIDE = 2
|
|
134
|
+
const SHINE_STRIDE = 3
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Frames are spaced to fit the budget, not the other way round, so a longer
|
|
138
|
+
* name means a quicker step rather than a longer wait.
|
|
139
|
+
*
|
|
140
|
+
* @param {number} width
|
|
141
|
+
* @param {number} [budget]
|
|
142
|
+
*/
|
|
143
|
+
export function schedule(width, budget = BUDGET_MS) {
|
|
144
|
+
const stride = REVEAL_STRIDE
|
|
145
|
+
const shineStride = SHINE_STRIDE
|
|
146
|
+
const frames = Math.floor(width / stride) + 1 + Math.ceil(width / shineStride) + 1
|
|
147
|
+
const delay = Math.max(4, Math.floor(budget / frames))
|
|
148
|
+
return { stride, shineStride, delay, frames, total: frames * delay }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function bannerEnabled(stream = process.stdout, env = process.env) {
|
|
152
|
+
return motionEnabled(stream, env)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isPrefix(spans, column) {
|
|
156
|
+
const span = spans.find((entry) => column >= entry.from && column <= entry.to)
|
|
157
|
+
return Boolean(span) && span.index < PREFIX_LETTERS
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One frame of the scan.
|
|
162
|
+
*
|
|
163
|
+
* `band` is where the bright head sits. `revealed` is how far the wordmark has
|
|
164
|
+
* been drawn at all; columns beyond it are blank. The reveal pass moves both
|
|
165
|
+
* together; the shine pass afterwards moves the band across a wordmark that is
|
|
166
|
+
* already complete.
|
|
167
|
+
*
|
|
168
|
+
* With `colour` off no escape is written at all: the head is a full block over
|
|
169
|
+
* the shaded prefix, which is still visible as a change in density, and over
|
|
170
|
+
* the solid name it is simply the name.
|
|
171
|
+
*/
|
|
172
|
+
export function frame(layout, band, revealed = band, { colour = true } = {}) {
|
|
173
|
+
const { rows, spans, width } = layout
|
|
174
|
+
return rows.map((row) => {
|
|
175
|
+
let out = ""
|
|
176
|
+
let tint = ""
|
|
177
|
+
for (let column = 0; column < width; column += 1) {
|
|
178
|
+
// Unrevealed columns, and blanks inside revealed ones, are plain spaces.
|
|
179
|
+
// A colour code is only ever emitted for a block that is actually drawn,
|
|
180
|
+
// so a frame carries no escape it does not use.
|
|
181
|
+
const lit = column <= revealed && row[column] === "#"
|
|
182
|
+
if (!lit) {
|
|
183
|
+
if (tint) { out += RESET; tint = "" }
|
|
184
|
+
out += " "
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
const atHead = column >= band - 1 && column <= band
|
|
188
|
+
const prefix = isPrefix(spans, column)
|
|
189
|
+
const wanted = colour ? (atHead ? TINT.head : prefix ? TINT.prefix : TINT.suffix) : ""
|
|
190
|
+
if (tint !== wanted) { out += wanted; tint = wanted }
|
|
191
|
+
out += atHead ? INK.head : prefix ? INK.prefix : INK.suffix
|
|
192
|
+
}
|
|
193
|
+
return tint ? out + RESET : out
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {{ word?: string, tagline?: string, stream?: NodeJS.WriteStream,
|
|
199
|
+
* enabled?: boolean, animate?: boolean, shines?: number,
|
|
200
|
+
* effect?: boolean, env?: NodeJS.ProcessEnv }} [options]
|
|
201
|
+
* `effect: true` runs the wordmark through `ttfx` when it is there (see
|
|
202
|
+
* effect.mjs); both callers, the front door and `setup`, pass it.
|
|
203
|
+
*/
|
|
204
|
+
export async function banner(options = {}) {
|
|
205
|
+
const stream = options.stream || process.stdout
|
|
206
|
+
const enabled = options.enabled ?? bannerEnabled(stream)
|
|
207
|
+
const colour = options.colour ?? colourEnabled(stream)
|
|
208
|
+
const word = options.word || "omakit"
|
|
209
|
+
const layout = wordmarkLayout(word)
|
|
210
|
+
const { width } = layout
|
|
211
|
+
// The rule and the tagline sit under the wordmark and are still meant to be
|
|
212
|
+
// read, so neither is dim: on a low-contrast theme grey-on-near-black is a
|
|
213
|
+
// decoration nobody can see. The rule takes the prefix tint, the tagline the
|
|
214
|
+
// foreground.
|
|
215
|
+
const c = (name, text) => (colour ? `${ESC}${name}m${text}${RESET}` : text)
|
|
216
|
+
const rule = floorRule((_name, text) => c(code("typeable"), text), { width })
|
|
217
|
+
// The tagline is centred under the wordmark, not set flush left: the rule
|
|
218
|
+
// is exactly as wide as the letters, so a shorter line starting at column
|
|
219
|
+
// 0 reads as slid to the left. The padding is spaces, no escape, so the
|
|
220
|
+
// line is centred under NO_COLOR and in a pipe alike.
|
|
221
|
+
const tagline = options.tagline
|
|
222
|
+
? " ".repeat(Math.max(0, Math.floor((width - [...String(options.tagline)].length) / 2))) + c(code("prose"), options.tagline)
|
|
223
|
+
: null
|
|
224
|
+
|
|
225
|
+
// Nothing at all when it is not a terminal. There is no plain-text substitute
|
|
226
|
+
// to print: `help` and `setup` already say the name and what it does in words,
|
|
227
|
+
// and a piped run should differ from a watched one only in decoration.
|
|
228
|
+
if (!enabled) return
|
|
229
|
+
|
|
230
|
+
// A five-row animation redrawn with cursor-up needs five rows that stay put.
|
|
231
|
+
// In a terminal with no room the screen scrolls under the animation, the
|
|
232
|
+
// cursor-up lands a line off, and a row from an earlier frame is left stranded
|
|
233
|
+
// above the wordmark. Rather than animate into that, draw it at once.
|
|
234
|
+
const rowsAvailable = Number.isFinite(stream.rows) ? stream.rows : Infinity
|
|
235
|
+
const animate = options.animate !== false && rowsAvailable >= GLYPH_ROWS + 4
|
|
236
|
+
|
|
237
|
+
const { stride, shineStride, delay } = schedule(width, options.budgetMs)
|
|
238
|
+
const paint = (lines) => lines.map((line) => `${ESC}2K${line}`).join("\n")
|
|
239
|
+
// A frame is written without a trailing newline, and the cursor walks back up
|
|
240
|
+
// four rows and to column 0. That is not a detail: a newline written while the
|
|
241
|
+
// cursor is on the last row of the screen scrolls the screen, so a frame that
|
|
242
|
+
// ends in one scrolls once per frame when the prompt happens to sit at the
|
|
243
|
+
// bottom, which it usually does. Twenty-six of those leave the partial frames
|
|
244
|
+
// in the scrollback and a stray row of one of them directly above the
|
|
245
|
+
// wordmark. Ending inside the block instead means the screen scrolls exactly
|
|
246
|
+
// once, for the very first frame, before any cursor-up is issued.
|
|
247
|
+
const redraw = (lines) => stream.write(`${paint(lines)}${ESC}${GLYPH_ROWS - 1}A\r`)
|
|
248
|
+
const finish = (lines) => stream.write(`${paint(lines)}\n`)
|
|
249
|
+
|
|
250
|
+
const draw = (band, revealed, tinted = colour) => frame(layout, band, revealed, { colour: tinted })
|
|
251
|
+
|
|
252
|
+
// The text effect, where asked for and where `ttfx` is there. It draws the
|
|
253
|
+
// plain glyphs, whose density split is in the characters, and leaves the
|
|
254
|
+
// cursor hidden on the line under them; omakit walks back up over the five
|
|
255
|
+
// rows and paints the finished wordmark in its own tints. Absent, the scan
|
|
256
|
+
// below runs exactly as it would have, byte for byte.
|
|
257
|
+
const env = options.env || process.env
|
|
258
|
+
const played = animate && options.effect && effectAvailable(env)
|
|
259
|
+
? await playEffect(draw(-2, width + 2, false), stream, { env })
|
|
260
|
+
: "absent"
|
|
261
|
+
// The one return pass over the finished wordmark, the shine,
|
|
262
|
+
// on the scan's own schedule. Two looked better and cost twice the budget,
|
|
263
|
+
// and the budget is the point.
|
|
264
|
+
const step = async (band, revealed, delay) => {
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
266
|
+
redraw(draw(band, revealed))
|
|
267
|
+
}
|
|
268
|
+
const shine = async () => {
|
|
269
|
+
const shines = options.shines ?? 1
|
|
270
|
+
for (let pass = 0; pass < shines; pass += 1) {
|
|
271
|
+
const forward = pass % 2 === 1
|
|
272
|
+
for (let step_ = 0; step_ <= Math.ceil(width / shineStride); step_ += 1) {
|
|
273
|
+
const offset = step_ * shineStride
|
|
274
|
+
await step(forward ? offset : width - offset, width + 2, delay)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (played === "played") {
|
|
280
|
+
// Back up over the effect's plain rows, paint the wordmark in omakit's
|
|
281
|
+
// tints, and end it the way the scan ends: with the shine.
|
|
282
|
+
stream.write(`${ESC}${GLYPH_ROWS}A`)
|
|
283
|
+
redraw(draw(-2, width + 2))
|
|
284
|
+
await shine()
|
|
285
|
+
finish(draw(-2, width + 2))
|
|
286
|
+
stream.write(`${ESC}?25h`)
|
|
287
|
+
} else if (played === "broken") {
|
|
288
|
+
// Something reached the screen and then the effect failed. The cursor is
|
|
289
|
+
// somewhere inside the rows, so the honest thing is to leave what is
|
|
290
|
+
// there, show the cursor, start a fresh line and draw the wordmark once.
|
|
291
|
+
stream.write(`${ESC}?25h\n`)
|
|
292
|
+
finish(draw(-2, width + 2))
|
|
293
|
+
} else if (!animate) {
|
|
294
|
+
finish(draw(-2, width + 2))
|
|
295
|
+
} else {
|
|
296
|
+
// Claim the five rows first, so whatever scrolling has to happen happens
|
|
297
|
+
// here, before a single cursor-up is issued and while the geometry can
|
|
298
|
+
// still shift harmlessly.
|
|
299
|
+
redraw(draw(-2, -2))
|
|
300
|
+
for (let band = 0; band <= width; band += stride) await step(band, band, delay)
|
|
301
|
+
await shine()
|
|
302
|
+
finish(draw(-2, width + 2))
|
|
303
|
+
}
|
|
304
|
+
stream.write(`${rule}\n`)
|
|
305
|
+
if (tagline) stream.write(`${tagline}\n`)
|
|
306
|
+
stream.write("\n")
|
|
307
|
+
}
|