fix: gate skills by OS
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Skills: switch imsg installer to brew tap formula.
|
- Skills: switch imsg installer to brew tap formula.
|
||||||
|
- Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.
|
||||||
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
||||||
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
|
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
|
||||||
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ Fields under `metadata.clawdis`:
|
|||||||
- `always: true` — always include the skill (skip other gates).
|
- `always: true` — always include the skill (skip other gates).
|
||||||
- `emoji` — optional emoji used by the macOS Skills UI.
|
- `emoji` — optional emoji used by the macOS Skills UI.
|
||||||
- `homepage` — optional URL shown as “Website” in the macOS Skills UI.
|
- `homepage` — optional URL shown as “Website” in the macOS Skills UI.
|
||||||
|
- `os` — optional list of platforms (`darwin`, `linux`, `win32`). If set, the skill is only eligible on those OSes.
|
||||||
- `requires.bins` — list; each must exist on `PATH`.
|
- `requires.bins` — list; each must exist on `PATH`.
|
||||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||||
- `requires.config` — list of `clawdis.json` paths that must be truthy.
|
- `requires.config` — list of `clawdis.json` paths that must be truthy.
|
||||||
@@ -78,6 +79,7 @@ metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":
|
|||||||
Notes:
|
Notes:
|
||||||
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
||||||
- Node installs honor `skills.install.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn/bun).
|
- Node installs honor `skills.install.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn/bun).
|
||||||
|
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
|
||||||
|
|
||||||
If no `metadata.clawdis` is present, the skill is always eligible (unless
|
If no `metadata.clawdis` is present, the skill is always eligible (unless
|
||||||
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name: imsg
|
name: imsg
|
||||||
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
|
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
|
||||||
homepage: https://imsg.to
|
homepage: https://imsg.to
|
||||||
metadata: {"clawdis":{"emoji":"📨","requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}}
|
metadata: {"clawdis":{"emoji":"📨","os":["darwin"],"requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}}
|
||||||
---
|
---
|
||||||
|
|
||||||
# imsg
|
# imsg
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name: peekaboo
|
name: peekaboo
|
||||||
description: Capture and automate macOS UI with the Peekaboo CLI.
|
description: Capture and automate macOS UI with the Peekaboo CLI.
|
||||||
homepage: https://peekaboo.boo
|
homepage: https://peekaboo.boo
|
||||||
metadata: {"clawdis":{"emoji":"👀","requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}}
|
metadata: {"clawdis":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}}
|
||||||
---
|
---
|
||||||
|
|
||||||
# Peekaboo
|
# Peekaboo
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name: things-mac
|
name: things-mac
|
||||||
description: Manage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database). Use when a user asks Clawdis to add a task to Things, list inbox/today/upcoming, search tasks, or inspect projects/areas/tags.
|
description: Manage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database). Use when a user asks Clawdis to add a task to Things, list inbox/today/upcoming, search tasks, or inspect projects/areas/tags.
|
||||||
homepage: https://github.com/ossianhempel/things3-cli
|
homepage: https://github.com/ossianhempel/things3-cli
|
||||||
metadata: {"clawdis":{"emoji":"✅","requires":{"bins":["things"]},"install":[{"id":"go","kind":"go","module":"github.com/ossianhempel/things3-cli/cmd/things@latest","bins":["things"],"label":"Install things3-cli (go)"}]}}
|
metadata: {"clawdis":{"emoji":"✅","os":["darwin"],"requires":{"bins":["things"]},"install":[{"id":"go","kind":"go","module":"github.com/ossianhempel/things3-cli/cmd/things@latest","bins":["things"],"label":"Install things3-cli (go)"}]}}
|
||||||
---
|
---
|
||||||
|
|
||||||
# Things 3 CLI
|
# Things 3 CLI
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import type { ClawdisConfig } from "../config/config.js";
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
@@ -88,6 +91,29 @@ function buildInstallCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveBrewBinDir(timeoutMs: number): Promise<string | undefined> {
|
||||||
|
if (!hasBinary("brew")) return undefined;
|
||||||
|
const prefixResult = await runCommandWithTimeout(["brew", "--prefix"], {
|
||||||
|
timeoutMs: Math.min(timeoutMs, 30_000),
|
||||||
|
});
|
||||||
|
if (prefixResult.code === 0) {
|
||||||
|
const prefix = prefixResult.stdout.trim();
|
||||||
|
if (prefix) return path.join(prefix, "bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPrefix = process.env.HOMEBREW_PREFIX?.trim();
|
||||||
|
if (envPrefix) return path.join(envPrefix, "bin");
|
||||||
|
|
||||||
|
for (const candidate of ["/opt/homebrew/bin", "/usr/local/bin"]) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function installSkill(
|
export async function installSkill(
|
||||||
params: SkillInstallRequest,
|
params: SkillInstallRequest,
|
||||||
): Promise<SkillInstallResult> {
|
): Promise<SkillInstallResult> {
|
||||||
@@ -130,6 +156,15 @@ export async function installSkill(
|
|||||||
code: null,
|
code: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (spec.kind === "brew" && !hasBinary("brew")) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "brew not installed",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (spec.kind === "uv" && !hasBinary("uv")) {
|
if (spec.kind === "uv" && !hasBinary("uv")) {
|
||||||
if (hasBinary("brew")) {
|
if (hasBinary("brew")) {
|
||||||
const brewResult = await runCommandWithTimeout(
|
const brewResult = await runCommandWithTimeout(
|
||||||
@@ -167,14 +202,51 @@ export async function installSkill(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (spec.kind === "go" && !hasBinary("go")) {
|
||||||
|
if (hasBinary("brew")) {
|
||||||
|
const brewResult = await runCommandWithTimeout(["brew", "install", "go"], {
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
if (brewResult.code !== 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Failed to install go (brew)",
|
||||||
|
stdout: brewResult.stdout.trim(),
|
||||||
|
stderr: brewResult.stderr.trim(),
|
||||||
|
code: brewResult.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "go not installed (install via brew)",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let env: NodeJS.ProcessEnv | undefined;
|
||||||
|
if (spec.kind === "go" && hasBinary("brew")) {
|
||||||
|
const brewBin = await resolveBrewBinDir(timeoutMs);
|
||||||
|
if (brewBin) env = { GOBIN: brewBin };
|
||||||
|
}
|
||||||
|
|
||||||
const result = await (async () => {
|
const result = await (async () => {
|
||||||
const argv = command.argv;
|
const argv = command.argv;
|
||||||
if (!argv || argv.length === 0) {
|
if (!argv || argv.length === 0) {
|
||||||
return { code: null, stdout: "", stderr: "invalid install command" };
|
return { code: null, stdout: "", stderr: "invalid install command" };
|
||||||
}
|
}
|
||||||
return runCommandWithTimeout(argv, {
|
try {
|
||||||
timeoutMs,
|
return await runCommandWithTimeout(argv, {
|
||||||
});
|
timeoutMs,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const stderr = err instanceof Error ? err.message : String(err);
|
||||||
|
return { code: null, stdout: "", stderr };
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const success = result.code === 0;
|
const success = result.code === 0;
|
||||||
|
|||||||
@@ -47,11 +47,13 @@ export type SkillStatusEntry = {
|
|||||||
bins: string[];
|
bins: string[];
|
||||||
env: string[];
|
env: string[];
|
||||||
config: string[];
|
config: string[];
|
||||||
|
os: string[];
|
||||||
};
|
};
|
||||||
missing: {
|
missing: {
|
||||||
bins: string[];
|
bins: string[];
|
||||||
env: string[];
|
env: string[];
|
||||||
config: string[];
|
config: string[];
|
||||||
|
os: string[];
|
||||||
};
|
};
|
||||||
configChecks: SkillStatusConfigCheck[];
|
configChecks: SkillStatusConfigCheck[];
|
||||||
install: SkillInstallOption[];
|
install: SkillInstallOption[];
|
||||||
@@ -149,8 +151,13 @@ function buildSkillStatus(
|
|||||||
const requiredBins = entry.clawdis?.requires?.bins ?? [];
|
const requiredBins = entry.clawdis?.requires?.bins ?? [];
|
||||||
const requiredEnv = entry.clawdis?.requires?.env ?? [];
|
const requiredEnv = entry.clawdis?.requires?.env ?? [];
|
||||||
const requiredConfig = entry.clawdis?.requires?.config ?? [];
|
const requiredConfig = entry.clawdis?.requires?.config ?? [];
|
||||||
|
const requiredOs = entry.clawdis?.os ?? [];
|
||||||
|
|
||||||
const missingBins = requiredBins.filter((bin) => !hasBinary(bin));
|
const missingBins = requiredBins.filter((bin) => !hasBinary(bin));
|
||||||
|
const missingOs =
|
||||||
|
requiredOs.length > 0 && !requiredOs.includes(process.platform)
|
||||||
|
? requiredOs
|
||||||
|
: [];
|
||||||
|
|
||||||
const missingEnv: string[] = [];
|
const missingEnv: string[] = [];
|
||||||
for (const envName of requiredEnv) {
|
for (const envName of requiredEnv) {
|
||||||
@@ -174,15 +181,21 @@ function buildSkillStatus(
|
|||||||
.map((check) => check.path);
|
.map((check) => check.path);
|
||||||
|
|
||||||
const missing = always
|
const missing = always
|
||||||
? { bins: [], env: [], config: [] }
|
? { bins: [], env: [], config: [], os: [] }
|
||||||
: { bins: missingBins, env: missingEnv, config: missingConfig };
|
: {
|
||||||
|
bins: missingBins,
|
||||||
|
env: missingEnv,
|
||||||
|
config: missingConfig,
|
||||||
|
os: missingOs,
|
||||||
|
};
|
||||||
const eligible =
|
const eligible =
|
||||||
!disabled &&
|
!disabled &&
|
||||||
!blockedByAllowlist &&
|
!blockedByAllowlist &&
|
||||||
(always ||
|
(always ||
|
||||||
(missing.bins.length === 0 &&
|
(missing.bins.length === 0 &&
|
||||||
missing.env.length === 0 &&
|
missing.env.length === 0 &&
|
||||||
missing.config.length === 0));
|
missing.config.length === 0 &&
|
||||||
|
missing.os.length === 0));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: entry.skill.name,
|
name: entry.skill.name,
|
||||||
@@ -202,6 +215,7 @@ function buildSkillStatus(
|
|||||||
bins: requiredBins,
|
bins: requiredBins,
|
||||||
env: requiredEnv,
|
env: requiredEnv,
|
||||||
config: requiredConfig,
|
config: requiredConfig,
|
||||||
|
os: requiredOs,
|
||||||
},
|
},
|
||||||
missing,
|
missing,
|
||||||
configChecks,
|
configChecks,
|
||||||
|
|||||||
@@ -369,6 +369,32 @@ describe("buildWorkspaceSkillStatus", () => {
|
|||||||
expect(skill?.install[0]?.id).toBe("brew");
|
expect(skill?.install[0]?.id).toBe("brew");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("respects OS-gated skills", async () => {
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||||
|
const skillDir = path.join(workspaceDir, "skills", "os-skill");
|
||||||
|
|
||||||
|
await writeSkill({
|
||||||
|
dir: skillDir,
|
||||||
|
name: "os-skill",
|
||||||
|
description: "Darwin only",
|
||||||
|
metadata: '{"clawdis":{"os":["darwin"]}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||||
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
|
});
|
||||||
|
const skill = report.skills.find((entry) => entry.name === "os-skill");
|
||||||
|
|
||||||
|
expect(skill).toBeDefined();
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
expect(skill?.eligible).toBe(true);
|
||||||
|
expect(skill?.missing.os).toEqual([]);
|
||||||
|
} else {
|
||||||
|
expect(skill?.eligible).toBe(false);
|
||||||
|
expect(skill?.missing.os).toEqual(["darwin"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("marks bundled skills blocked by allowlist", async () => {
|
it("marks bundled skills blocked by allowlist", async () => {
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||||
const bundledDir = path.join(workspaceDir, ".bundled");
|
const bundledDir = path.join(workspaceDir, ".bundled");
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type ClawdisSkillMetadata = {
|
|||||||
primaryEnv?: string;
|
primaryEnv?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
homepage?: string;
|
homepage?: string;
|
||||||
|
os?: string[];
|
||||||
requires?: {
|
requires?: {
|
||||||
bins?: string[];
|
bins?: string[];
|
||||||
env?: string[];
|
env?: string[];
|
||||||
@@ -188,6 +189,10 @@ export function resolveSkillsInstallPreferences(
|
|||||||
return { preferBrew, nodeManager };
|
return { preferBrew, nodeManager };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRuntimePlatform(): string {
|
||||||
|
return process.platform;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveConfigPath(
|
export function resolveConfigPath(
|
||||||
config: ClawdisConfig | undefined,
|
config: ClawdisConfig | undefined,
|
||||||
pathStr: string,
|
pathStr: string,
|
||||||
@@ -280,6 +285,7 @@ function resolveClawdisMetadata(
|
|||||||
const install = installRaw
|
const install = installRaw
|
||||||
.map((entry) => parseInstallSpec(entry))
|
.map((entry) => parseInstallSpec(entry))
|
||||||
.filter((entry): entry is SkillInstallSpec => Boolean(entry));
|
.filter((entry): entry is SkillInstallSpec => Boolean(entry));
|
||||||
|
const osRaw = normalizeStringList(clawdisObj.os);
|
||||||
return {
|
return {
|
||||||
always:
|
always:
|
||||||
typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined,
|
typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined,
|
||||||
@@ -297,6 +303,7 @@ function resolveClawdisMetadata(
|
|||||||
typeof clawdisObj.primaryEnv === "string"
|
typeof clawdisObj.primaryEnv === "string"
|
||||||
? clawdisObj.primaryEnv
|
? clawdisObj.primaryEnv
|
||||||
: undefined,
|
: undefined,
|
||||||
|
os: osRaw.length > 0 ? osRaw : undefined,
|
||||||
requires: requiresRaw
|
requires: requiresRaw
|
||||||
? {
|
? {
|
||||||
bins: normalizeStringList(requiresRaw.bins),
|
bins: normalizeStringList(requiresRaw.bins),
|
||||||
@@ -323,9 +330,13 @@ function shouldIncludeSkill(params: {
|
|||||||
const skillKey = resolveSkillKey(entry.skill, entry);
|
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||||
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
|
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
|
||||||
|
const osList = entry.clawdis?.os ?? [];
|
||||||
|
|
||||||
if (skillConfig?.enabled === false) return false;
|
if (skillConfig?.enabled === false) return false;
|
||||||
if (!isBundledSkillAllowed(entry, allowBundled)) return false;
|
if (!isBundledSkillAllowed(entry, allowBundled)) return false;
|
||||||
|
if (osList.length > 0 && !osList.includes(resolveRuntimePlatform())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (entry.clawdis?.always === true) {
|
if (entry.clawdis?.always === true) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,16 +222,19 @@ export type SkillStatusEntry = {
|
|||||||
homepage?: string;
|
homepage?: string;
|
||||||
always: boolean;
|
always: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
blockedByAllowlist: boolean;
|
||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
requirements: {
|
requirements: {
|
||||||
bins: string[];
|
bins: string[];
|
||||||
env: string[];
|
env: string[];
|
||||||
config: string[];
|
config: string[];
|
||||||
|
os: string[];
|
||||||
};
|
};
|
||||||
missing: {
|
missing: {
|
||||||
bins: string[];
|
bins: string[];
|
||||||
env: string[];
|
env: string[];
|
||||||
config: string[];
|
config: string[];
|
||||||
|
os: string[];
|
||||||
};
|
};
|
||||||
configChecks: SkillsStatusConfigCheck[];
|
configChecks: SkillsStatusConfigCheck[];
|
||||||
install: SkillInstallOption[];
|
install: SkillInstallOption[];
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ export function renderSkills(props: SkillsProps) {
|
|||||||
function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
||||||
const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name;
|
const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name;
|
||||||
const apiKey = props.edits[skill.skillKey] ?? "";
|
const apiKey = props.edits[skill.skillKey] ?? "";
|
||||||
|
const missing = [
|
||||||
|
...skill.missing.bins.map((b) => `bin:${b}`),
|
||||||
|
...skill.missing.env.map((e) => `env:${e}`),
|
||||||
|
...skill.missing.config.map((c) => `config:${c}`),
|
||||||
|
...skill.missing.os.map((o) => `os:${o}`),
|
||||||
|
];
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (skill.disabled) reasons.push("disabled");
|
||||||
|
if (skill.blockedByAllowlist) reasons.push("blocked by allowlist");
|
||||||
return html`
|
return html`
|
||||||
<div class="list-item">
|
<div class="list-item">
|
||||||
<div class="list-main">
|
<div class="list-main">
|
||||||
@@ -87,14 +96,17 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
|||||||
</span>
|
</span>
|
||||||
${skill.disabled ? html`<span class="chip chip-warn">disabled</span>` : nothing}
|
${skill.disabled ? html`<span class="chip chip-warn">disabled</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
${skill.missing.bins.length + skill.missing.env.length + skill.missing.config.length > 0
|
${missing.length > 0
|
||||||
? html`
|
? html`
|
||||||
<div class="muted" style="margin-top: 6px;">
|
<div class="muted" style="margin-top: 6px;">
|
||||||
Missing: ${[
|
Missing: ${missing.join(", ")}
|
||||||
...skill.missing.bins.map((b) => `bin:${b}`),
|
</div>
|
||||||
...skill.missing.env.map((e) => `env:${e}`),
|
`
|
||||||
...skill.missing.config.map((c) => `config:${c}`),
|
: nothing}
|
||||||
].join(", ")}
|
${reasons.length > 0
|
||||||
|
? html`
|
||||||
|
<div class="muted" style="margin-top: 6px;">
|
||||||
|
Reason: ${reasons.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
@@ -143,4 +155,3 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user