dimagent-linux-x64
Advanced tools
| import importlib.util | ||
| import sys | ||
| import unittest | ||
| from pathlib import Path | ||
| from PIL import Image, ImageDraw | ||
| SKILL_DIR = Path(__file__).resolve().parents[1] | ||
| EXTRACT_PATH = SKILL_DIR / "scripts" / "extract_strip_frames.py" | ||
| sys.path.insert(0, str(EXTRACT_PATH.parent)) | ||
| SPEC = importlib.util.spec_from_file_location("extract_strip_frames", EXTRACT_PATH) | ||
| if SPEC is None or SPEC.loader is None: | ||
| raise RuntimeError(f"Unable to load {EXTRACT_PATH}") | ||
| EXTRACT = importlib.util.module_from_spec(SPEC) | ||
| SPEC.loader.exec_module(EXTRACT) | ||
| def _filled(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int]) -> None: | ||
| draw.rectangle(box, fill=(0, 0, 0, 255)) | ||
| class ExtractStripFramesFrameBleedTest(unittest.TestCase): | ||
| """A detached fragment must stay in the column that owns it. | ||
| The strip has two poses. Frame 0 owns a detached "arm" that sits inside its | ||
| own column slot but whose ``center_x`` is nearer frame 1's body center. The | ||
| old nearest-``center_x`` assignment pulled the arm across the boundary into | ||
| frame 1 (frame 0 lost it, frame 1 gained a stray). Slot-based assignment | ||
| keeps it in frame 0. | ||
| """ | ||
| def _strip(self) -> Image.Image: | ||
| # width 400, 2 frames -> slot_width 200; boundary at x=200. | ||
| strip = Image.new("RGBA", (400, 208), (0, 0, 0, 0)) | ||
| draw = ImageDraw.Draw(strip) | ||
| _filled(draw, (20, 40, 100, 180)) # frame 0 body, center_x 60, slot 0 | ||
| _filled(draw, (210, 40, 290, 180)) # frame 1 body, center_x 250, slot 1 | ||
| # frame 0's detached arm: center_x 165 (slot 0) but nearer seed 1 (250) | ||
| # than seed 0 (60); separated from both bodies by a transparent gap. | ||
| _filled(draw, (150, 90, 180, 130)) | ||
| return strip | ||
| def test_detached_fragment_stays_in_its_own_column(self) -> None: | ||
| strip = self._strip() | ||
| groups = EXTRACT.component_frame_groups(strip, 2) | ||
| self.assertIsNotNone(groups) | ||
| assert groups is not None | ||
| def has_arm(group: list[dict[str, object]]) -> bool: | ||
| return any(150 <= component["center_x"] <= 180 for component in group) | ||
| # Groups are ordered left-to-right by seed center_x. | ||
| self.assertTrue(has_arm(groups[0]), "frame 0 should keep its own arm") | ||
| self.assertFalse(has_arm(groups[1]), "frame 1 must not receive the stray arm") | ||
| def test_neither_frame_keeps_a_detached_fragment(self) -> None: | ||
| strip = self._strip() | ||
| frames = EXTRACT.extract_component_frames(strip, 2) | ||
| self.assertIsNotNone(frames) | ||
| assert frames is not None | ||
| # The arm is grouped into its own slot (frame 0, not frame 1) — proven by | ||
| # test_detached_fragment_stays_in_its_own_column — and then cleaned as a | ||
| # detached fragment, so neither frame carries a stray blob. | ||
| self.assertEqual(len(EXTRACT.connected_components(frames[0])), 1) | ||
| self.assertEqual(len(EXTRACT.connected_components(frames[1])), 1) | ||
| class ExtractStripFramesDebrisTest(unittest.TestCase): | ||
| """A small free-floating island inside a frame's slot must be dropped. | ||
| Chroma/despill specks and detached fur wisps land in the strip beyond the | ||
| poses. When one sits in a frame's column it survives grouping (it is above | ||
| the noise floor and owns that slot) and would bake in as a stray pixel blob. | ||
| The finished cell must keep only the pose body. | ||
| """ | ||
| def _strip_with_speck(self) -> Image.Image: | ||
| strip = Image.new("RGBA", (400, 208), (0, 0, 0, 0)) | ||
| draw = ImageDraw.Draw(strip) | ||
| _filled(draw, (20, 40, 100, 180)) # frame 0 body (slot 0) | ||
| _filled(draw, (210, 40, 290, 180)) # frame 1 body (slot 1) | ||
| # frame 0 debris: 10x10 = 100 px, above the noise floor but below the | ||
| # 128 px detached floor, separated from the body by a transparent gap. | ||
| _filled(draw, (150, 95, 160, 105)) | ||
| return strip | ||
| def test_small_detached_island_is_removed(self) -> None: | ||
| frames = EXTRACT.extract_component_frames(self._strip_with_speck(), 2) | ||
| self.assertIsNotNone(frames) | ||
| assert frames is not None | ||
| # Both cells are body-only; the speck is gone. | ||
| self.assertEqual(len(EXTRACT.connected_components(frames[0])), 1) | ||
| self.assertEqual(len(EXTRACT.connected_components(frames[1])), 1) | ||
| @staticmethod | ||
| def _fitted(rects: list[tuple[int, int, int, int]]) -> Image.Image: | ||
| cell = Image.new("RGBA", (192, 208), (0, 0, 0, 0)) | ||
| draw = ImageDraw.Draw(cell) | ||
| for rect in rects: | ||
| _filled(draw, rect) | ||
| return EXTRACT.fit_to_cell(cell) | ||
| def test_detached_medium_fragment_is_dropped(self) -> None: | ||
| # ~650 px fragment (well above 128) sitting ~29 px off a ~11k body, only | ||
| # ~6% of it — the baobao-style echo the 128 floor and 15% ratio both miss. | ||
| result = self._fitted([(40, 40, 120, 180), (150, 90, 170, 120)]) | ||
| self.assertEqual(len(EXTRACT.connected_components(result)), 1) | ||
| def test_attached_part_is_kept(self) -> None: | ||
| # A >=128 px part with a 2 px gap to the body is a real attached detail. | ||
| result = self._fitted([(40, 40, 120, 180), (123, 90, 153, 130)]) | ||
| self.assertEqual(len(EXTRACT.connected_components(result)), 2) | ||
| def test_large_second_mass_is_kept(self) -> None: | ||
| # A detached component that is >=15% of the main body is a genuine second | ||
| # mass; extraction keeps it and the row inspector flags the row instead. | ||
| result = self._fitted([(40, 40, 110, 170), (140, 40, 180, 120)]) | ||
| self.assertEqual(len(EXTRACT.connected_components(result)), 2) | ||
| if __name__ == "__main__": | ||
| unittest.main() |
| import json | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import unittest | ||
| from pathlib import Path | ||
| from PIL import Image, ImageDraw | ||
| SKILL_DIR = Path(__file__).resolve().parents[1] | ||
| INSPECT_PATH = SKILL_DIR / "scripts" / "inspect_frames.py" | ||
| def _cell(secondary: tuple[int, int, int, int] | None = None) -> Image.Image: | ||
| """A 192x208 cell: one rectangular body (100x130 = 13000 px), optionally a | ||
| detached secondary blob well to its left.""" | ||
| cell = Image.new("RGBA", (192, 208), (0, 0, 0, 0)) | ||
| draw = ImageDraw.Draw(cell) | ||
| draw.rectangle((50, 40, 150, 170), fill=(200, 150, 120, 255)) # main body | ||
| if secondary is not None: | ||
| draw.rectangle(secondary, fill=(200, 150, 120, 255)) | ||
| return cell | ||
| class InspectFramesSecondaryComponentTest(unittest.TestCase): | ||
| """A frame with a second pose-sized component fails the row (crammed pose / stray).""" | ||
| def _run(self, cells: list[Image.Image]) -> tuple[int, dict]: | ||
| tmp = tempfile.TemporaryDirectory() | ||
| self.addCleanup(tmp.cleanup) | ||
| root = Path(tmp.name) | ||
| state_dir = root / "running-left" | ||
| state_dir.mkdir(parents=True) | ||
| for index, cell in enumerate(cells): | ||
| cell.save(state_dir / f"{index:02d}.png") | ||
| (root / "frames-manifest.json").write_text( | ||
| json.dumps( | ||
| { | ||
| "chroma_key": {"rgb": [255, 0, 255]}, | ||
| "rows": [{"state": "running-left", "method": "components"}], | ||
| } | ||
| ) | ||
| ) | ||
| report_path = root / "report.json" | ||
| completed = subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(INSPECT_PATH), | ||
| "--frames-root", | ||
| str(root), | ||
| "--json-out", | ||
| str(report_path), | ||
| "--states", | ||
| "running-left", | ||
| "--require-components", | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| return completed.returncode, json.loads(report_path.read_text()) | ||
| def test_clean_row_passes(self) -> None: | ||
| code, report = self._run([_cell() for _ in range(8)]) | ||
| self.assertEqual(code, 0) | ||
| self.assertTrue(report["ok"]) | ||
| def test_second_pose_sized_component_fails_the_row(self) -> None: | ||
| cells = [_cell() for _ in range(8)] | ||
| # Frame 3 gains a detached 31x71 ~= 2200 px second blob (>128 and >15% of body). | ||
| cells[3] = _cell(secondary=(5, 70, 35, 140)) | ||
| code, report = self._run(cells) | ||
| self.assertNotEqual(code, 0) | ||
| self.assertFalse(report["ok"]) | ||
| self.assertTrue( | ||
| any("second pose-sized component" in error for error in report["errors"]), | ||
| report["errors"], | ||
| ) | ||
| def test_tiny_second_component_is_ignored(self) -> None: | ||
| cells = [_cell() for _ in range(8)] | ||
| # A sub-floor speck (6x6 = 36 px < 128) must NOT fail the row. | ||
| cells[3] = _cell(secondary=(10, 90, 16, 96)) | ||
| code, report = self._run(cells) | ||
| self.assertEqual(code, 0, report["errors"]) | ||
| self.assertTrue(report["ok"]) | ||
| if __name__ == "__main__": | ||
| unittest.main() |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| """Where installed skills live, and what they are actually called. | ||
| A skill's identity is the `name` in its SKILL.md frontmatter, not its directory | ||
| name: that is the name the runtime loads it under, and two directories claiming | ||
| the same name means only one of them is ever reachable. Everything here reads | ||
| that name, and reads it from every root the runtime scans — including skills | ||
| that arrive inside plugin bundles, which a scan of the loose skills directories | ||
| alone would miss. | ||
| """ | ||
| from __future__ import annotations | ||
| from dataclasses import dataclass | ||
| import os | ||
| def dimcode_home() -> str: | ||
| """`DIMCODE_HOME` -> `$XDG_CONFIG_HOME/.dimcode/v2` -> `~/.dimcode/v2`.""" | ||
| explicit = os.environ.get("DIMCODE_HOME", "").strip() | ||
| if explicit: | ||
| return os.path.abspath(os.path.expanduser(explicit)) | ||
| xdg = os.environ.get("XDG_CONFIG_HOME", "").strip() | ||
| if xdg: | ||
| return os.path.abspath(os.path.join(os.path.expanduser(xdg), ".dimcode", "v2")) | ||
| return os.path.abspath(os.path.expanduser("~/.dimcode/v2")) | ||
| def agents_home() -> str: | ||
| return os.path.expanduser(os.environ.get("AGENTS_HOME", "~/.agents")) | ||
| def private_skills_root() -> str: | ||
| """Where dimcode installs skills it manages.""" | ||
| return os.path.join(dimcode_home(), "skills") | ||
| def shared_skills_root() -> str: | ||
| """Cross-agent skills directory; read here, written only on explicit request.""" | ||
| return os.path.join(agents_home(), "skills") | ||
| def private_plugins_root() -> str: | ||
| return os.path.join(dimcode_home(), "plugins") | ||
| def shared_plugins_root() -> str: | ||
| return os.path.join(agents_home(), "plugins") | ||
| def plugin_roots() -> list[str]: | ||
| return [private_plugins_root(), shared_plugins_root()] | ||
| @dataclass | ||
| class InstalledSkill: | ||
| """A skill already on disk, identified the way the runtime identifies it.""" | ||
| name: str | ||
| path: str | ||
| #: "private" | "shared" | "plugin" | ||
| origin: str | ||
| #: Owning plugin's directory name when origin == "plugin". | ||
| plugin: str | None = None | ||
| def describe(self) -> str: | ||
| if self.origin == "plugin": | ||
| return f"{self.name} (from plugin {self.plugin}, {self.path})" | ||
| where = "dimcode" if self.origin == "private" else "shared agents dir" | ||
| return f"{self.name} ({where}, {self.path})" | ||
| def read_skill_name(skill_dir: str) -> str | None: | ||
| """The frontmatter `name`, or None when this isn't a readable skill dir. | ||
| Falls back to the directory name only when the file exists but declares no | ||
| name — the runtime does the same, and refusing to see such a skill would | ||
| make it invisible to the conflict check that exists to protect it. | ||
| """ | ||
| skill_md = os.path.join(skill_dir, "SKILL.md") | ||
| if not os.path.isfile(skill_md): | ||
| return None | ||
| try: | ||
| with open(skill_md, encoding="utf-8") as handle: | ||
| head = handle.read(4096) | ||
| except OSError: | ||
| return None | ||
| if not head.startswith("---"): | ||
| return os.path.basename(skill_dir.rstrip("/\\")) | ||
| end = head.find("\n---", 3) | ||
| frontmatter = head[3:end] if end != -1 else head[3:] | ||
| for line in frontmatter.splitlines(): | ||
| key, sep, value = line.partition(":") | ||
| if sep and key.strip() == "name": | ||
| name = value.strip().strip("'\"") | ||
| if name: | ||
| return name | ||
| return os.path.basename(skill_dir.rstrip("/\\")) | ||
| def _scan_loose(root: str, origin: str) -> list[InstalledSkill]: | ||
| if not os.path.isdir(root): | ||
| return [] | ||
| found: list[InstalledSkill] = [] | ||
| for entry in sorted(os.listdir(root)): | ||
| path = os.path.join(root, entry) | ||
| if not os.path.isdir(path): | ||
| continue | ||
| name = read_skill_name(path) | ||
| if name: | ||
| found.append(InstalledSkill(name=name, path=path, origin=origin)) | ||
| return found | ||
| def _scan_plugins() -> list[InstalledSkill]: | ||
| found: list[InstalledSkill] = [] | ||
| for root in plugin_roots(): | ||
| if not os.path.isdir(root): | ||
| continue | ||
| for plugin in sorted(os.listdir(root)): | ||
| skills_dir = os.path.join(root, plugin, "skills") | ||
| if not os.path.isdir(skills_dir): | ||
| continue | ||
| for entry in sorted(os.listdir(skills_dir)): | ||
| path = os.path.join(skills_dir, entry) | ||
| if not os.path.isdir(path): | ||
| continue | ||
| name = read_skill_name(path) | ||
| if name: | ||
| found.append( | ||
| InstalledSkill(name=name, path=path, origin="plugin", plugin=plugin) | ||
| ) | ||
| return found | ||
| def installed_skills() -> list[InstalledSkill]: | ||
| """Every skill the runtime can currently see, across every root.""" | ||
| return [ | ||
| *_scan_loose(private_skills_root(), "private"), | ||
| *_scan_loose(shared_skills_root(), "shared"), | ||
| *_scan_plugins(), | ||
| ] | ||
| def installed_by_name() -> dict[str, list[InstalledSkill]]: | ||
| out: dict[str, list[InstalledSkill]] = {} | ||
| for skill in installed_skills(): | ||
| out.setdefault(skill.name, []).append(skill) | ||
| return out | ||
| def installed_plugin_dirs() -> dict[str, str]: | ||
| """Installed plugin directory name -> path, first root wins.""" | ||
| out: dict[str, str] = {} | ||
| for root in plugin_roots(): | ||
| if not os.path.isdir(root): | ||
| continue | ||
| for entry in sorted(os.listdir(root)): | ||
| path = os.path.join(root, entry) | ||
| if entry in out or not os.path.isdir(path): | ||
| continue | ||
| if ".installing-" in entry or ".backup-" in entry: | ||
| continue | ||
| out[entry] = path | ||
| return out |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| """Importing skills: where they land, and when the install must refuse. | ||
| Everything after the fetch is exercised here — classifying what the path holds, | ||
| refusing a name that is already taken, packaging a collection into one plugin — | ||
| by pointing the script at a local fixture instead of GitHub. The transport | ||
| itself (zip download, git sparse checkout) is untouched by these tests. | ||
| """ | ||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import textwrap | ||
| import unittest | ||
| from pathlib import Path | ||
| SKILL_DIR = Path(__file__).resolve().parents[1] | ||
| SCRIPTS_DIR = SKILL_DIR / "scripts" | ||
| def write_skill(directory: Path, name: str, description: str = "does things") -> Path: | ||
| directory.mkdir(parents=True, exist_ok=True) | ||
| (directory / "SKILL.md").write_text( | ||
| f"---\nname: {name}\ndescription: {description}\n---\nbody\n", encoding="utf-8" | ||
| ) | ||
| return directory | ||
| # Runs the script's main() with the fetch stubbed out to a local directory, and | ||
| # with `dim` replaced by a stub that behaves like the real plugin installer: | ||
| # it copies the staged bundle into the plugins root and prints the JSON record. | ||
| DRIVER = textwrap.dedent( | ||
| """ | ||
| import json, os, shutil, sys | ||
| sys.path.insert(0, os.environ["SCRIPTS_DIR"]) | ||
| import importlib.util | ||
| spec = importlib.util.spec_from_file_location( | ||
| "installer", os.path.join(os.environ["SCRIPTS_DIR"], "install-skill-from-github.py") | ||
| ) | ||
| installer = importlib.util.module_from_spec(spec) | ||
| sys.modules["installer"] = installer | ||
| spec.loader.exec_module(installer) | ||
| installer._prepare_repo = lambda source, method, tmp_dir: os.environ["REPO_ROOT"] | ||
| def fake_install_bundle(bundle_root, bundle_name, provenance_source, ref, overwrite, source_paths=None): | ||
| import skill_roots | ||
| dest = os.path.join(skill_roots.private_plugins_root(), bundle_name) | ||
| os.makedirs(os.path.dirname(dest), exist_ok=True) | ||
| if os.path.exists(dest): | ||
| if not overwrite: | ||
| raise installer.InstallError("already installed") | ||
| shutil.rmtree(dest) | ||
| shutil.copytree(bundle_root, dest) | ||
| with open(os.path.join(dest, "dim-install.json"), "w", encoding="utf-8") as fh: | ||
| json.dump( | ||
| { | ||
| "source": provenance_source, | ||
| "ref": ref, | ||
| "generated": True, | ||
| "generatedPaths": source_paths or [], | ||
| "generatedPluginName": bundle_name, | ||
| }, fh | ||
| ) | ||
| return dest | ||
| installer._install_bundle = fake_install_bundle | ||
| raise SystemExit(installer.main(json.loads(os.environ["ARGV"]))) | ||
| """ | ||
| ) | ||
| class InstallSkillTest(unittest.TestCase): | ||
| def setUp(self) -> None: | ||
| self._tmp = tempfile.TemporaryDirectory() | ||
| root = Path(self._tmp.name) | ||
| self.home = root / "home" | ||
| self.dimcode_home = self.home / ".dimcode" / "v2" | ||
| self.repo = root / "repo" | ||
| (self.home / ".agents" / "skills").mkdir(parents=True) | ||
| (self.dimcode_home / "skills").mkdir(parents=True) | ||
| self.addCleanup(self._tmp.cleanup) | ||
| def run_installer(self, argv: list[str]) -> subprocess.CompletedProcess: | ||
| env = { | ||
| **os.environ, | ||
| "HOME": str(self.home), | ||
| "AGENTS_HOME": str(self.home / ".agents"), | ||
| "DIMCODE_HOME": str(self.dimcode_home), | ||
| "SCRIPTS_DIR": str(SCRIPTS_DIR), | ||
| "REPO_ROOT": str(self.repo), | ||
| "ARGV": json.dumps(argv), | ||
| } | ||
| env.pop("XDG_CONFIG_HOME", None) | ||
| return subprocess.run( | ||
| [sys.executable, "-c", DRIVER], | ||
| capture_output=True, | ||
| text=True, | ||
| env=env, | ||
| ) | ||
| def test_installs_into_dimcode_own_root_by_default(self) -> None: | ||
| write_skill(self.repo / "pdf", "pdf") | ||
| result = self.run_installer(["--repo", "acme/skills", "--path", "pdf"]) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertTrue((self.dimcode_home / "skills" / "pdf" / "SKILL.md").is_file()) | ||
| self.assertFalse((self.home / ".agents" / "skills" / "pdf").exists()) | ||
| def test_shared_flag_installs_into_the_cross_agent_directory(self) -> None: | ||
| write_skill(self.repo / "pdf", "pdf") | ||
| result = self.run_installer(["--repo", "acme/skills", "--path", "pdf", "--shared"]) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertTrue((self.home / ".agents" / "skills" / "pdf" / "SKILL.md").is_file()) | ||
| self.assertFalse((self.dimcode_home / "skills" / "pdf").exists()) | ||
| def test_directory_name_is_not_the_skill_identity(self) -> None: | ||
| # The runtime resolves the frontmatter name, so that is what the install | ||
| # is named after — not the folder it happened to sit in upstream. | ||
| write_skill(self.repo / "folder-name", "actual-name") | ||
| result = self.run_installer(["--repo", "acme/skills", "--path", "folder-name"]) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertTrue((self.dimcode_home / "skills" / "actual-name").is_dir()) | ||
| def test_lists_candidates_for_a_collection_directory(self) -> None: | ||
| write_skill(self.repo / "web" / "better-ui", "better-ui") | ||
| write_skill(self.repo / "web" / "better-colors", "better-colors") | ||
| result = self.run_installer( | ||
| ["--repo", "acme/skills", "--path", "web", "--list-candidates", "--format", "json"] | ||
| ) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| payload = json.loads(result.stdout) | ||
| names = sorted(skill["name"] for skill in payload[0]["skills"]) | ||
| self.assertEqual(names, ["better-colors", "better-ui"]) | ||
| def test_refuses_a_name_already_taken_by_a_loose_skill(self) -> None: | ||
| write_skill(self.repo / "pdf", "pdf") | ||
| write_skill(self.home / ".agents" / "skills" / "pdf", "pdf") | ||
| result = self.run_installer(["--repo", "acme/skills", "--path", "pdf"]) | ||
| self.assertEqual(result.returncode, 1) | ||
| self.assertIn("already installed under the same name", result.stderr) | ||
| self.assertFalse((self.dimcode_home / "skills" / "pdf").exists()) | ||
| def test_refuses_a_name_already_taken_by_a_plugin_bundled_skill(self) -> None: | ||
| # A bundled skill occupies the name just as a loose one does; scanning | ||
| # only the skills directories would miss it and install a dead copy. | ||
| write_skill(self.repo / "better-ui", "better-ui") | ||
| write_skill(self.dimcode_home / "plugins" / "web-pack" / "skills" / "better-ui", "better-ui") | ||
| result = self.run_installer(["--repo", "acme/skills", "--path", "better-ui"]) | ||
| self.assertEqual(result.returncode, 1) | ||
| self.assertIn("from plugin web-pack", result.stderr) | ||
| def test_packages_a_collection_into_one_plugin(self) -> None: | ||
| write_skill(self.repo / "web" / "better-ui", "better-ui") | ||
| write_skill(self.repo / "web" / "better-colors", "better-colors") | ||
| result = self.run_installer( | ||
| [ | ||
| "--repo", "acme/skills", | ||
| "--path", "web/better-ui", "web/better-colors", | ||
| "--as-plugin", "--plugin-name", "web-pack", | ||
| ] | ||
| ) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| bundle = self.dimcode_home / "plugins" / "web-pack" | ||
| manifest = json.loads((bundle / ".codex-plugin" / "plugin.json").read_text()) | ||
| self.assertEqual(manifest["name"], "web-pack") | ||
| self.assertEqual(manifest["description"], "") | ||
| self.assertEqual(manifest["skills"], "./skills") | ||
| self.assertTrue((bundle / "skills" / "better-ui" / "SKILL.md").is_file()) | ||
| self.assertTrue((bundle / "skills" / "better-colors" / "SKILL.md").is_file()) | ||
| # Provenance records the origin so a later reinstall can rebuild it. | ||
| provenance = json.loads((bundle / "dim-install.json").read_text()) | ||
| self.assertEqual(provenance["source"], "acme/skills") | ||
| self.assertTrue(provenance["generated"]) | ||
| # Nothing leaks into the loose skills directories. | ||
| self.assertFalse((self.dimcode_home / "skills" / "better-ui").exists()) | ||
| def test_rejects_dest_combined_with_as_plugin(self) -> None: | ||
| write_skill(self.repo / "pdf", "pdf") | ||
| result = self.run_installer( | ||
| ["--repo", "acme/skills", "--path", "pdf", "--as-plugin", "--dest", "/tmp/x"] | ||
| ) | ||
| self.assertEqual(result.returncode, 1) | ||
| self.assertIn("--dest cannot be combined with --as-plugin", result.stderr) | ||
| if __name__ == "__main__": | ||
| unittest.main() |
| --- | ||
| name: skill-migrator | ||
| description: Migrate existing Codex, Claude, or npx skills into Dim. Use when a user wants to bring skills from ~/.codex/skills, ~/.claude/skills, ~/.agents/skills, project skill directories, or another local skill folder into Dim, especially when several skills should be managed together as one plugin. | ||
| metadata: | ||
| short-description: Migrate Codex and Claude skills into Dim | ||
| --- | ||
| # Skill Migrator | ||
| Move existing skills into Dim's managed installation model without losing the | ||
| source files, metadata, scripts, references, or assets. | ||
| This skill is for migration, not for silently copying a directory. Always inspect | ||
| the source first, explain what will happen, and preserve the original until the | ||
| Dim installation has been verified. | ||
| ## Supported sources | ||
| Check these locations in this order when the user says "my Codex skills", | ||
| "my Claude skills", or "skills installed with npx skills": | ||
| 1. `~/.agents/skills/` — the usual shared user-level skills directory. | ||
| 2. `~/.codex/skills/` — Codex-specific user skills, when present. | ||
| 3. `~/.claude/skills/` — Claude user-level skills, when present. | ||
| 4. `<project>/.agents/skills/` — shared project-scoped skills. | ||
| 5. `<project>/.claude/skills/` — Claude project-scoped skills. | ||
| 6. A local directory or repository path explicitly supplied by the user. | ||
| Do not assume that a directory name is the skill identity. Read each | ||
| `SKILL.md` frontmatter and use its canonical `name`. A valid skill directory | ||
| must contain a readable `SKILL.md` with non-empty `name` and `description` | ||
| fields. | ||
| ## Classify before migrating | ||
| Inspect the source root and classify it: | ||
| - A directory containing `SKILL.md` is one skill. | ||
| - A directory without `SKILL.md` whose child directories contain two or more | ||
| valid `SKILL.md` files is a skill collection. | ||
| - A root containing `.codex-plugin/plugin.json` or | ||
| `.claude-plugin/plugin.json` is already a plugin. Do not wrap it in another | ||
| plugin; use the existing plugin install flow. | ||
| - A repository containing a marketplace catalog is a plugin marketplace source, | ||
| not a loose skill collection. | ||
| If the source is ambiguous, show the discovered paths and ask the user to | ||
| choose. Never guess a nested directory or silently skip invalid entries. | ||
| ## Choose the destination | ||
| For one skill, install it as a standalone Dim skill unless the user requests a | ||
| plugin. | ||
| For two or more skills from the same source, recommend one plugin: | ||
| ```text | ||
| Found N skills from <source>. | ||
| Recommended: import them as one plugin | ||
| - one enable/disable switch | ||
| - one uninstall boundary | ||
| - individual skill switches on the plugin detail page | ||
| Alternative: import them as standalone skills | ||
| - each skill is managed separately in Skills | ||
| ``` | ||
| If the user chooses a plugin, derive a stable lower-case hyphenated plugin name | ||
| from the source or collection directory. Ask for a name when the derived name | ||
| would be empty, ambiguous, or already installed. Use the real collection name | ||
| as the plugin display name when it is available. | ||
| Do not offer `~/.agents/skills` as a Dim installation destination by default. | ||
| That directory is shared with other agents and should only be written when the | ||
| user explicitly asks for cross-agent sharing. Dim-managed output belongs in its | ||
| private skills or plugins root. | ||
| ## Migration procedure | ||
| 1. Discover candidate directories and read their `SKILL.md` files. | ||
| 2. Report the canonical names, source paths, and any invalid or duplicate names. | ||
| 3. Check all skill discovery roots for canonical-name conflicts, including | ||
| existing Dim skills, shared skills, project skills, and skills inside plugins. | ||
| 4. Stop on a conflict. Report both paths and ask the user to rename, remove, or | ||
| exclude one. Never silently rename a skill because the runtime resolves | ||
| skills by canonical name. | ||
| 5. Preserve the complete skill directory. Copy `SKILL.md`, `scripts/`, | ||
| `references/`, `assets/`, `agents/`, and other files unless the user | ||
| explicitly asks for a minimal import. | ||
| 6. Install through the existing Dim skill/plugin installer. Do not implement a | ||
| second ad-hoc copy protocol in the conversation. | ||
| 7. Verify the installed result by rescanning Dim's skills and plugins, checking | ||
| canonical names and the plugin component list. | ||
| 8. Only after verification, offer to archive or remove the original. Never | ||
| delete `~/.codex/skills`, `~/.claude/skills`, `~/.agents/skills`, or a | ||
| project directory without explicit confirmation. | ||
| ## Plugin layout | ||
| When importing a collection as one plugin, the resulting bundle should have | ||
| this shape: | ||
| ```text | ||
| <plugin>/ | ||
| ├── .codex-plugin/plugin.json | ||
| └── skills/ | ||
| ├── <skill-a>/SKILL.md | ||
| ├── <skill-b>/SKILL.md | ||
| └── ... | ||
| ``` | ||
| For a skills-only migration, this is the complete Dim manifest contract. Use it | ||
| directly; do not pause to research or reconfirm the format after the user has | ||
| already selected the skills and the plugin installation mode: | ||
| ```json | ||
| { | ||
| "name": "<lower-case-hyphenated-plugin-id>", | ||
| "version": "0.0.0", | ||
| "description": "", | ||
| "interface": { | ||
| "displayName": "<real collection or user-facing title>" | ||
| }, | ||
| "skills": "./skills" | ||
| } | ||
| ``` | ||
| Manifest rules: | ||
| - Write the file at `<bundle>/.codex-plugin/plugin.json`. | ||
| - `name` is the stable internal id and plugin directory name. It must be | ||
| non-empty, lower-case hyphen-case, and contain only letters, digits, `.`, `_`, | ||
| or `-`. | ||
| - `interface.displayName` is the real user-facing collection title. It does not | ||
| need to match `name`. | ||
| - Use `version: "0.0.0"` for a generated migration bundle unless the source | ||
| provides a meaningful bundle version. | ||
| - Keep `description: ""` when no collection description exists; do not insert | ||
| placeholder text. | ||
| - Use exactly `skills: "./skills"` and place every migrated skill at | ||
| `<bundle>/skills/<directory>/SKILL.md`. | ||
| - Do not add `hooks`, `mcpServers`, `agents`, or other manifest fields for a | ||
| skills-only migration. | ||
| - Keep each skill's canonical frontmatter name unchanged. | ||
| After creating the temporary bundle, install it through: | ||
| ```bash | ||
| dim plugin install <bundle-path> --generated --provenance-source <source-path> | ||
| ``` | ||
| Use `--overwrite` only when the user explicitly chose to replace an existing | ||
| plugin with the same internal name. The plugin installer owns final validation, | ||
| conflict checks, provenance, and destination placement. Do not manually copy the | ||
| finished bundle into Dim's plugins directory. | ||
| Once the user has selected the skills and chosen "install as one plugin", proceed | ||
| with bundle creation and installation. Do not answer with "let me confirm the | ||
| manifest format" or ask the same installation-mode question again. | ||
| ## npx skills guidance | ||
| When the user says they installed skills with `npx skills`, first inspect the | ||
| user and project skill roots above. The CLI may have installed a single skill, | ||
| several skills from a collection, or a plugin-shaped source. Use the files that | ||
| are actually present as the source of truth rather than reconstructing the | ||
| repository URL from memory. | ||
| If only a repository URL is available, use the existing `skill-installer` flow | ||
| to fetch and classify it. If the user asks for a plugin result, pass the | ||
| collection's skill directories through the plugin bundle path instead of | ||
| installing them loose and then copying them a second time. | ||
| ## Communication | ||
| Tell the user: | ||
| - what was found and where; | ||
| - whether the result is a standalone skill or a plugin; | ||
| - the Dim-managed destination; | ||
| - which original files remain untouched; | ||
| - whether a new session or reload is needed before the migrated skill can be | ||
| used by the agent. | ||
| If migration is blocked, report the exact conflict or invalid file and stop. |
@@ -106,4 +106,4 @@ { | ||
| "path": "facade/node_modules/sharp/node_modules/.bin/semver", | ||
| "size": 1842, | ||
| "sha256": "b35180e0d9290fc2b3a8a50abf59bf7e19086b5a5df6dc1473ed9c0598520ba4" | ||
| "size": 2009, | ||
| "sha256": "83d7a17b81c23c373e3e5d7740d59a7d9832e8713e9b235cabc805efae3f9e4b" | ||
| }, | ||
@@ -110,0 +110,0 @@ { |
@@ -12,7 +12,27 @@ --- | ||
| If the `chrome` tool is not available, Chrome Control is off. Tell the user to | ||
| enable it from the Desktop sidebar: **Skills / Plugins → Plugins → My Plugins → | ||
| Chrome Control**, and stop — do not try to reach Chrome another way. It is not | ||
| in Settings. | ||
| ## Setup triage | ||
| Chrome Control needs two things the user controls: the feature toggle in | ||
| Desktop, and the DimAgent extension installed in their Chrome. Both default to | ||
| off/absent, so on a fresh install neither is there. Diagnose before acting, tell | ||
| the user the one next step, and stop — do not try to reach Chrome another way. | ||
| 1. **No `chrome` tool at all** → the feature is off. Tell the user to turn it on | ||
| at **Skills / Plugins → Plugins → My Plugins → Chrome Control** (sidebar, not | ||
| Settings). Then tell them step 2 is next, so they do not have to come back | ||
| for it. | ||
| 2. **`chrome status` reports not connected** → the extension is missing or | ||
| Chrome is not running. The same Chrome Control page has **Setup Chrome | ||
| Extension** → *Open Chrome Web Store*; the user installs **DimAgent Chrome | ||
| Control** there and keeps Chrome open. Desktop connects on its own within a | ||
| few seconds — no restart of DimAgent needed. | ||
| 3. **Connected before, not connected now** → Chrome was quit, the extension was | ||
| disabled or removed, or another DimAgent instance took the connection over. | ||
| Ask the user to check Chrome is running with the extension enabled, then use | ||
| **Troubleshoot → Re-register Native Host** on the same page. | ||
| Do not claim Chrome Control is broken or unavailable in this build, and do not | ||
| guess between these three — `chrome status` distinguishes 2 from 3, and the tool | ||
| being absent entirely distinguishes 1. | ||
| ## When to use Chrome | ||
@@ -19,0 +39,0 @@ |
@@ -16,2 +16,17 @@ #!/usr/bin/env python3 | ||
| CELL_HEIGHT = 208 | ||
| # Detached-component floor for a finished cell. A connected component smaller | ||
| # than this that is not the frame's main mass is treated as debris (chroma/ | ||
| # despill speck, detached fur wisp) and dropped. Mirrors the look-row assembler | ||
| # (assemble_extended_atlas.MIN_DETACHED_COMPONENT_PIXELS); a follow-up task will | ||
| # unify the two and add proximity-based attach / fail-fast handling. | ||
| MIN_DETACHED_COMPONENT_PIXELS = 128 | ||
| # A non-main component is kept only if it is a genuine second mass (at least | ||
| # DETACHED_SECOND_ELEMENT_FRACTION of the main body — the row inspector then | ||
| # flags the row) or is attached to the body within ATTACH_GAP_PX. Everything | ||
| # else is debris. This closes the gap between the absolute floor above and the | ||
| # row-inspection ratio: a medium fragment (e.g. a 400-700 px echo of a | ||
| # neighbouring pose sitting 10-15 px off the body) is neither tiny nor large but | ||
| # is still debris. | ||
| DETACHED_SECOND_ELEMENT_FRACTION = 0.15 | ||
| ATTACH_GAP_PX = 3 | ||
| ROW_FRAME_COUNTS = { | ||
@@ -83,2 +98,5 @@ "idle": 6, | ||
| def fit_to_cell(image: Image.Image) -> Image.Image: | ||
| # Clean debris before measuring so the pose is centered on its own body, not | ||
| # skewed by a floating speck that is about to be removed anyway. | ||
| image = remove_small_detached_components(image) | ||
| bbox = image.getbbox() | ||
@@ -105,2 +123,3 @@ target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) | ||
| def fit_viewport_to_cell(image: Image.Image) -> Image.Image: | ||
| image = remove_small_detached_components(image) | ||
| target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) | ||
@@ -187,2 +206,46 @@ if image.getbbox() is None: | ||
| def _bbox_gap( | ||
| a: tuple[int, int, int, int], b: tuple[int, int, int, int] | ||
| ) -> int: | ||
| """Smallest axis separation between two bboxes; 0 when they touch or overlap.""" | ||
| dx = max(0, a[0] - b[2], b[0] - a[2]) | ||
| dy = max(0, a[1] - b[3], b[1] - a[3]) | ||
| return max(dx, dy) | ||
| def remove_small_detached_components(image: Image.Image) -> Image.Image: | ||
| """Drop free-floating debris from a finished cell. | ||
| The generated strip carries stray islands beyond the poses: chroma/despill | ||
| specks, fur wisps, or a medium echo of a neighbouring pose the model drew a | ||
| few pixels off the body. When one lands in a frame's column it survives | ||
| grouping and bakes in as a detached blob. Keep the largest connected mass; | ||
| keep a genuine second mass (>= ``DETACHED_SECOND_ELEMENT_FRACTION`` of it, | ||
| which the row inspector then flags) and a part touching the body within | ||
| ``ATTACH_GAP_PX``; clear everything else. | ||
| """ | ||
| rgba = image.convert("RGBA") | ||
| components = sorted( | ||
| connected_components(rgba), key=lambda component: component["area"], reverse=True | ||
| ) | ||
| if not components: | ||
| return rgba | ||
| main = components[0] | ||
| main_area = main["area"] | ||
| main_bbox = main["bbox"] | ||
| width = rgba.width | ||
| pixels = rgba.load() | ||
| for component in components[1:]: | ||
| area = component["area"] | ||
| attached = ( | ||
| area >= MIN_DETACHED_COMPONENT_PIXELS | ||
| and _bbox_gap(main_bbox, component["bbox"]) <= ATTACH_GAP_PX | ||
| ) | ||
| if area >= main_area * DETACHED_SECOND_ELEMENT_FRACTION or attached: | ||
| continue | ||
| for index in component["pixels"]: | ||
| pixels[index % width, index // width] = (0, 0, 0, 0) | ||
| return rgba | ||
| def component_group_image( | ||
@@ -236,7 +299,24 @@ source: Image.Image, | ||
| # Assign non-seed fragments (arms, feet, marks) by the equal-width column slot | ||
| # that owns their center, not by nearest seed center_x. A detached part of a | ||
| # walking pose stays in its own column instead of being pulled across a frame | ||
| # boundary when a neighbour's seed happens to sit closer (frame bleed). | ||
| slot_width = strip.width / frame_count | ||
| def slot_of(center_x: float) -> int: | ||
| return min(frame_count - 1, max(0, int(center_x // slot_width))) | ||
| seed_slots = [slot_of(seed["center_x"]) for seed in seeds] | ||
| for component in components: | ||
| if id(component) in seed_ids or component["area"] < noise_threshold: | ||
| continue | ||
| target_slot = slot_of(component["center_x"]) | ||
| candidates = [index for index, slot in enumerate(seed_slots) if slot == target_slot] | ||
| if not candidates: | ||
| # Degenerate strip where no seed occupies this column: fall back to the | ||
| # old nearest-center behaviour so the fragment is not dropped. | ||
| candidates = list(range(len(seeds))) | ||
| nearest_index = min( | ||
| range(len(seeds)), | ||
| candidates, | ||
| key=lambda index: abs(seeds[index]["center_x"] - component["center_x"]), | ||
@@ -243,0 +323,0 @@ ) |
@@ -45,2 +45,40 @@ #!/usr/bin/env python3 | ||
| def two_largest_component_areas(image: Image.Image) -> tuple[int, int]: | ||
| """Pixel areas of the two largest opaque (alpha > 16) 4-connected components. | ||
| A clean pose is a single connected mass, so the second area is ~0. A large | ||
| second area means the cell crammed more than one pose or kept a pose-sized | ||
| stray — the failure that concentrates on the leftward row when it is | ||
| generated independently instead of mirrored from the clean rightward row. | ||
| """ | ||
| alpha = image.getchannel("A") | ||
| width, height = alpha.size | ||
| data = alpha.tobytes() | ||
| total = width * height | ||
| visited = bytearray(total) | ||
| areas: list[int] = [] | ||
| for start in range(total): | ||
| if data[start] <= 16 or visited[start]: | ||
| continue | ||
| stack = [start] | ||
| visited[start] = 1 | ||
| count = 0 | ||
| while stack: | ||
| current = stack.pop() | ||
| count += 1 | ||
| x = current % width | ||
| for neighbour, in_bounds in ( | ||
| (current - 1, x > 0), | ||
| (current + 1, x + 1 < width), | ||
| (current - width, current - width >= 0), | ||
| (current + width, current + width < total), | ||
| ): | ||
| if in_bounds and not visited[neighbour] and data[neighbour] > 16: | ||
| visited[neighbour] = 1 | ||
| stack.append(neighbour) | ||
| areas.append(count) | ||
| areas.sort(reverse=True) | ||
| return (areas[0] if areas else 0, areas[1] if len(areas) > 1 else 0) | ||
| def edge_alpha_count(image: Image.Image, margin: int) -> int: | ||
@@ -165,2 +203,3 @@ alpha = image.getchannel("A") | ||
| ) | ||
| largest_component, second_component = two_largest_component_areas(frame) | ||
| info = { | ||
@@ -175,2 +214,4 @@ "index": index, | ||
| "chroma_adjacent_pixels": chroma_adjacent_pixels, | ||
| "largest_component_pixels": largest_component, | ||
| "second_component_pixels": second_component, | ||
| } | ||
@@ -196,2 +237,13 @@ frames.append(info) | ||
| ) | ||
| if ( | ||
| largest_component > 0 | ||
| and second_component >= args.secondary_component_min_pixels | ||
| and second_component >= largest_component * args.secondary_component_ratio | ||
| ): | ||
| row_errors.append( | ||
| f"{state} frame {index:02d} has a second pose-sized component " | ||
| f"({second_component} px, {second_component / largest_component:.0%} of the main body); " | ||
| "the row crammed more than one pose or kept a stray — regenerate the row, or mirror it " | ||
| "from the opposite direction when that is identity-safe" | ||
| ) | ||
@@ -235,2 +287,14 @@ if areas: | ||
| parser.add_argument( | ||
| "--secondary-component-ratio", | ||
| type=float, | ||
| default=0.15, | ||
| help="Fail a frame whose second connected component is at least this fraction of the main body.", | ||
| ) | ||
| parser.add_argument( | ||
| "--secondary-component-min-pixels", | ||
| type=int, | ||
| default=128, | ||
| help="Ignore second components below this size (matches the extractor's detached-debris floor).", | ||
| ) | ||
| parser.add_argument( | ||
| "--require-components", | ||
@@ -237,0 +301,0 @@ action="store_true", |
@@ -8,2 +8,3 @@ #!/usr/bin/env python3 | ||
| from dataclasses import dataclass | ||
| import json | ||
| import os | ||
@@ -19,2 +20,11 @@ import shutil | ||
| from github_utils import github_request | ||
| from skill_roots import ( | ||
| installed_by_name, | ||
| installed_plugin_dirs, | ||
| private_plugins_root, | ||
| private_skills_root, | ||
| read_skill_name, | ||
| shared_skills_root, | ||
| ) | ||
| DEFAULT_REF = "main" | ||
@@ -32,2 +42,8 @@ | ||
| method: str = "auto" | ||
| as_plugin: bool = False | ||
| plugin_name: str | None = None | ||
| shared: bool = False | ||
| overwrite: bool = False | ||
| list_candidates: bool = False | ||
| format: str = "text" | ||
@@ -48,6 +64,2 @@ | ||
| def _agents_home() -> str: | ||
| return os.path.expanduser("~/.agents") | ||
| def _tmp_root() -> str: | ||
@@ -247,6 +259,151 @@ base = os.path.join(tempfile.gettempdir(), "agents") | ||
| def _default_dest() -> str: | ||
| return os.path.join(_agents_home(), "skills") | ||
| def _default_bundle_name(source: Source) -> str: | ||
| """Name the bundle after the directory the skills came from. | ||
| A collection lives under a category path (`.../web`), which reads better as | ||
| a plugin name than the repo alone; a repo-root install falls back to the | ||
| repo name. | ||
| """ | ||
| if len(source.paths) == 1: | ||
| base = os.path.basename(source.paths[0].rstrip("/")) | ||
| if base: | ||
| return f"{source.repo}-{base}" | ||
| common = os.path.commonpath([p.rstrip("/") for p in source.paths]) if source.paths else "" | ||
| base = os.path.basename(common) | ||
| return f"{source.repo}-{base}" if base else source.repo | ||
| def _default_dest(shared: bool) -> str: | ||
| """Dimcode's own skills root, unless the caller explicitly asked to share. | ||
| Installing into `~/.agents/skills` makes the skill visible to other agents | ||
| but takes it out of dimcode's update path, so it is never the default — | ||
| only an explicit `--shared`. | ||
| """ | ||
| return shared_skills_root() if shared else private_skills_root() | ||
| def _find_candidates(path: str) -> list[dict[str, str]]: | ||
| """Skills one level under `path`, for a directory that is not itself a skill. | ||
| A collection repo points at a category directory, not at a skill, so the | ||
| caller needs the list before it can ask the user how to import them. | ||
| """ | ||
| if not os.path.isdir(path): | ||
| raise InstallError(f"Path not found: {path}") | ||
| found = [] | ||
| for entry in sorted(os.listdir(path)): | ||
| child = os.path.join(path, entry) | ||
| if not os.path.isdir(child): | ||
| continue | ||
| name = read_skill_name(child) | ||
| if name: | ||
| found.append({"dir": entry, "name": name}) | ||
| return found | ||
| def _assert_no_name_conflict(names: list[str]) -> None: | ||
| """Refuse to install a skill whose name is already taken. | ||
| Skills are resolved by name and deduped across roots, so a second skill | ||
| under an existing name is never the one that loads — installing it would | ||
| report success and leave the user with a skill that silently does nothing. | ||
| The user has to resolve it (remove, rename, or skip) before we write. | ||
| """ | ||
| existing = installed_by_name() | ||
| clashes = [] | ||
| for name in names: | ||
| for owner in existing.get(name, []): | ||
| clashes.append(f" {name}: already installed as {owner.describe()}") | ||
| if clashes: | ||
| raise InstallError( | ||
| "These skills are already installed under the same name:\n" | ||
| + "\n".join(clashes) | ||
| + "\nRemove or rename the existing one, or drop it from this install, then retry." | ||
| ) | ||
| def _synthesize_bundle( | ||
| staged: list[tuple[str, str]], | ||
| bundle_name: str, | ||
| tmp_dir: str, | ||
| ) -> str: | ||
| """Package skills into a plugin bundle laid out for the plugin installer. | ||
| Producing a real bundle — rather than writing into the plugins directory | ||
| directly — is what lets the shared installer do the manifest validation, | ||
| conflict check, provenance and atomic swap exactly once. | ||
| """ | ||
| bundle_root = os.path.join(tmp_dir, "bundle", bundle_name) | ||
| manifest_dir = os.path.join(bundle_root, ".codex-plugin") | ||
| os.makedirs(manifest_dir, exist_ok=True) | ||
| manifest = { | ||
| "name": bundle_name, | ||
| "version": "0.0.0", | ||
| # Required by the manifest summary; a synthesized bundle has no | ||
| # upstream description to carry, so it is explicitly empty. | ||
| "description": "", | ||
| "skills": "./skills", | ||
| "interface": {"displayName": bundle_name}, | ||
| } | ||
| with open(os.path.join(manifest_dir, "plugin.json"), "w", encoding="utf-8") as handle: | ||
| json.dump(manifest, handle, indent=2) | ||
| for skill_name, skill_src in staged: | ||
| _validate_skill_name(skill_name) | ||
| shutil.copytree(skill_src, os.path.join(bundle_root, "skills", skill_name)) | ||
| return bundle_root | ||
| def _dim_binary() -> str: | ||
| return os.environ.get("DIMCODE_CLI_BIN", "dim") | ||
| def _install_bundle( | ||
| bundle_root: str, | ||
| bundle_name: str, | ||
| provenance_source: str, | ||
| ref: str, | ||
| overwrite: bool, | ||
| source_paths: list[str] | None = None, | ||
| ) -> str: | ||
| """Hand the staged bundle to the shared plugin installer.""" | ||
| installed_plugins = installed_plugin_dirs() | ||
| if not overwrite and bundle_name in installed_plugins: | ||
| raise InstallError( | ||
| f'A plugin named "{bundle_name}" is already installed at ' | ||
| f"{installed_plugins[bundle_name]}.\n" | ||
| "Pass --plugin-name to install under another name, or --overwrite to replace it." | ||
| ) | ||
| cmd = [ | ||
| _dim_binary(), "plugin", "install", bundle_root, | ||
| "--provenance-source", provenance_source, | ||
| "--ref", ref, | ||
| "--generated", | ||
| "--generated-plugin-name", bundle_name, | ||
| "--json", | ||
| ] | ||
| for source_path in source_paths or []: | ||
| cmd.extend(["--generated-path", source_path]) | ||
| if overwrite: | ||
| cmd.append("--overwrite") | ||
| try: | ||
| result = subprocess.run( | ||
| cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True | ||
| ) | ||
| except OSError as exc: | ||
| raise InstallError( | ||
| f"Cannot run '{_dim_binary()} plugin install': {exc}. " | ||
| "Set DIMCODE_CLI_BIN if the CLI is not on PATH." | ||
| ) from exc | ||
| if result.returncode != 0: | ||
| message = (result.stderr or result.stdout).strip() | ||
| raise InstallError(f"Plugin install failed: {message or 'unknown error'}") | ||
| fallback = os.path.join(private_plugins_root(), bundle_name) | ||
| try: | ||
| installed = json.loads(result.stdout.strip().splitlines()[-1]) | ||
| except (ValueError, IndexError): | ||
| return fallback | ||
| return str(installed.get("path") or fallback) | ||
| def _parse_args(argv: list[str]) -> Args: | ||
@@ -271,8 +428,74 @@ parser = argparse.ArgumentParser(description="Install a skill from GitHub.") | ||
| ) | ||
| parser.add_argument( | ||
| "--as-plugin", | ||
| action="store_true", | ||
| help="Package the selected skills into one plugin instead of installing them loose", | ||
| ) | ||
| parser.add_argument( | ||
| "--plugin-name", | ||
| help="Plugin name for --as-plugin (defaults to the path or repo name)", | ||
| ) | ||
| parser.add_argument( | ||
| "--shared", | ||
| action="store_true", | ||
| help="Install into ~/.agents/skills so other agents see it (dimcode stops managing updates)", | ||
| ) | ||
| parser.add_argument( | ||
| "--overwrite", | ||
| action="store_true", | ||
| help="Replace an existing plugin of the same name (--as-plugin only)", | ||
| ) | ||
| parser.add_argument( | ||
| "--list-candidates", | ||
| action="store_true", | ||
| help="List the skills under --path instead of installing, for a collection directory", | ||
| ) | ||
| parser.add_argument("--format", choices=["text", "json"], default="text") | ||
| parser.add_argument("--generated-path", action="append", default=[], help=argparse.SUPPRESS) | ||
| parser.add_argument("--generated-plugin-name", help=argparse.SUPPRESS) | ||
| return parser.parse_args(argv, namespace=Args()) | ||
| def _report_candidates(paths: list[str], repo_root: str, fmt: str) -> int: | ||
| """Print what lives under a directory that is not itself a skill.""" | ||
| payload = [] | ||
| for path in paths: | ||
| target = os.path.join(repo_root, path) | ||
| payload.append({"path": path, "skills": _find_candidates(target)}) | ||
| if fmt == "json": | ||
| print(json.dumps(payload)) | ||
| return 0 | ||
| for entry in payload: | ||
| skills = entry["skills"] | ||
| if not skills: | ||
| print(f"{entry['path']}: no skills found") | ||
| continue | ||
| print(f"{entry['path']}: {len(skills)} skills") | ||
| for skill in skills: | ||
| print(f" {skill['name']}") | ||
| return 0 | ||
| def _stage_skills(source: Source, repo_root: str, args: Args) -> list[tuple[str, str]]: | ||
| """Resolve each requested path to (skill name, source directory).""" | ||
| staged: list[tuple[str, str]] = [] | ||
| for path in source.paths: | ||
| skill_src = os.path.join(repo_root, path) | ||
| _validate_skill(skill_src) | ||
| override = args.name if len(source.paths) == 1 else None | ||
| skill_name = override or read_skill_name(skill_src) or os.path.basename(path.rstrip("/")) | ||
| _validate_skill_name(skill_name) | ||
| staged.append((skill_name, skill_src)) | ||
| return staged | ||
| def main(argv: list[str]) -> int: | ||
| args = _parse_args(argv) | ||
| try: | ||
| if args.as_plugin and args.dest: | ||
| raise InstallError("--dest cannot be combined with --as-plugin.") | ||
| if args.as_plugin and args.shared: | ||
| raise InstallError( | ||
| "--shared cannot be combined with --as-plugin; a bundle installs as a plugin." | ||
| ) | ||
| source = _resolve_source(args) | ||
@@ -284,18 +507,36 @@ source.ref = source.ref or args.ref | ||
| _validate_relative_path(path) | ||
| dest_root = args.dest or _default_dest() | ||
| tmp_dir = tempfile.mkdtemp(prefix="skill-install-", dir=_tmp_root()) | ||
| try: | ||
| repo_root = _prepare_repo(source, args.method, tmp_dir) | ||
| if args.list_candidates: | ||
| return _report_candidates(source.paths, repo_root, args.format) | ||
| staged = _stage_skills(source, repo_root, args) | ||
| # Names are how the runtime resolves skills, so a clash makes the | ||
| # new copy unreachable. Check before writing anything, whichever | ||
| # mode we are in. | ||
| _assert_no_name_conflict([name for name, _ in staged]) | ||
| if args.as_plugin: | ||
| bundle_name = args.plugin_name or _default_bundle_name(source) | ||
| _validate_skill_name(bundle_name) | ||
| bundle_root = _synthesize_bundle(staged, bundle_name, tmp_dir) | ||
| path = _install_bundle( | ||
| bundle_root, | ||
| bundle_name, | ||
| f"{source.owner}/{source.repo}", | ||
| source.ref, | ||
| args.overwrite, | ||
| source.paths, | ||
| ) | ||
| print(f"Installed plugin {bundle_name} ({len(staged)} skills) to {path}") | ||
| return 0 | ||
| dest_root = args.dest or _default_dest(args.shared) | ||
| installed = [] | ||
| for path in source.paths: | ||
| skill_name = args.name if len(source.paths) == 1 else None | ||
| skill_name = skill_name or os.path.basename(path.rstrip("/")) | ||
| _validate_skill_name(skill_name) | ||
| if not skill_name: | ||
| raise InstallError("Unable to derive skill name.") | ||
| for skill_name, skill_src in staged: | ||
| dest_dir = os.path.join(dest_root, skill_name) | ||
| if os.path.exists(dest_dir): | ||
| raise InstallError(f"Destination already exists: {dest_dir}") | ||
| skill_src = os.path.join(repo_root, path) | ||
| _validate_skill(skill_src) | ||
| _copy_skill(skill_src, dest_dir) | ||
@@ -302,0 +543,0 @@ installed.append((skill_name, dest_dir)) |
@@ -13,2 +13,3 @@ #!/usr/bin/env python3 | ||
| from github_utils import github_api_contents_url, github_request | ||
| from skill_roots import installed_by_name | ||
@@ -35,18 +36,17 @@ DEFAULT_REPO = "openai/skills" | ||
| def _agents_home() -> str: | ||
| return os.path.expanduser("~/.agents") | ||
| def _installed_skills() -> dict[str, str]: | ||
| """Installed skill name -> where it came from. | ||
| Keyed by the SKILL.md `name` rather than the directory name (that is what | ||
| the runtime resolves), and covering every root it scans — including skills | ||
| that arrive inside plugin bundles, which would otherwise be reported as not | ||
| installed and then collide on install. | ||
| """ | ||
| return { | ||
| name: owners[0].describe() | ||
| for name, owners in installed_by_name().items() | ||
| if owners | ||
| } | ||
| def _installed_skills() -> set[str]: | ||
| root = os.path.join(_agents_home(), "skills") | ||
| if not os.path.isdir(root): | ||
| return set() | ||
| entries = set() | ||
| for name in os.listdir(root): | ||
| path = os.path.join(root, name) | ||
| if os.path.isdir(path): | ||
| entries.add(name) | ||
| return entries | ||
| def _list_skills(repo: str, path: str, ref: str) -> list[str]: | ||
@@ -95,3 +95,8 @@ api_url = github_api_contents_url(repo, path, ref) | ||
| payload = [ | ||
| {"name": name, "installed": name in installed} for name in skills | ||
| { | ||
| "name": name, | ||
| "installed": name in installed, | ||
| **({"installedAs": installed[name]} if name in installed else {}), | ||
| } | ||
| for name in skills | ||
| ] | ||
@@ -101,3 +106,3 @@ print(json.dumps(payload)) | ||
| for idx, name in enumerate(skills, start=1): | ||
| suffix = " (already installed)" if name in installed else "" | ||
| suffix = f" (already installed — {installed[name]})" if name in installed else "" | ||
| print(f"{idx}. {name}{suffix}") | ||
@@ -104,0 +109,0 @@ return 0 |
| --- | ||
| name: skill-installer | ||
| description: Install Agents skills into $AGENTS_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). | ||
| description: Install Agents skills from a curated list or a GitHub repo, either loose or packaged as one plugin. Use when a user asks to list installable skills, install a curated skill, install a skill from another repo (including private repos), or import a whole collection of skills. | ||
| metadata: | ||
@@ -17,4 +17,68 @@ short-description: Install curated skills from openai/skills or other repos | ||
| ## Step 1: Classify the source before installing | ||
| Not every path a user points at is a single skill. Check what it actually is: | ||
| | What is at the path | How to tell | What to do | | ||
| | --- | --- | --- | | ||
| | One skill | the directory contains `SKILL.md` | install it directly, no questions | | ||
| | A collection / category | no `SKILL.md`, but subdirectories have one | **ask how to import** (step 2) | | ||
| | A plugin | repo root has `.codex-plugin/plugin.json` or `.claude-plugin/plugin.json` | not this skill's job — use `dim plugin install <source>` | | ||
| | A marketplace | repo has a `marketplace.json` catalog | not this skill's job — use `dim plugin marketplace add <source>` | | ||
| `scripts/install-skill-from-github.py --list-candidates --path <dir>` prints the | ||
| skills under a directory (add `--format json` to read it back reliably). Use it | ||
| when the path is not itself a skill. | ||
| ## Step 2: Ask how to import a collection | ||
| When two or more skills come from the same place, ask once — the answer changes | ||
| how the user manages them afterwards, and it can't be inferred. Use a structured | ||
| question tool if one is available; otherwise ask in plain text: | ||
| ``` | ||
| Found a skill collection at vercel-labs/agent-skills (web/, 5 skills): | ||
| better-ui, better-colors, better-layout, better-typography, better-writing | ||
| How should these be installed? | ||
| 1. As one plugin "agent-skills-web" (recommended) | ||
| Appears under Plugins: enable, disable or remove all five together, | ||
| with per-skill switches on its detail page. | ||
| 2. Loose | ||
| Appears as five separate entries under Skills, managed one by one. | ||
| 3. Loose, into the shared ~/.agents/skills directory | ||
| Other agents (Codex and so on) can see them, but Dim no longer | ||
| manages their updates. | ||
| ``` | ||
| When to ask: | ||
| | Situation | What to do | | ||
| | --- | --- | | ||
| | One skill | install loose, don't ask | | ||
| | Two or more skills from one source | ask once, then reuse that answer for the rest of the conversation | | ||
| | The user already said how they want it | don't ask | | ||
| | No one is there to answer | install loose and mention `--as-plugin` in the result | | ||
| ## Step 3: Install | ||
| Install skills with the helper scripts. | ||
| Loose (default — installs into Dim's own skills directory): | ||
| ``` | ||
| scripts/install-skill-from-github.py --repo <owner>/<repo> --path <path/to/skill> [<path/to/skill> ...] | ||
| ``` | ||
| As one plugin: | ||
| ``` | ||
| scripts/install-skill-from-github.py --repo <owner>/<repo> --path <dir>/<skill-a> <dir>/<skill-b> --as-plugin [--plugin-name <name>] | ||
| ``` | ||
| `--as-plugin` packages the skills into a plugin bundle and hands it to | ||
| `dim plugin install`, so the bundle goes through the same manifest check, | ||
| conflict check and provenance the plugin market uses. Upgrading it later is a | ||
| reinstall: `dim plugin install <source> --overwrite`. | ||
| ## Communication | ||
@@ -43,2 +107,3 @@ | ||
| - Example (experimental skill): `scripts/install-skill-from-github.py --repo openai/skills --path skills/.experimental/<skill-name>` | ||
| - Example (collection as a plugin): `scripts/install-skill-from-github.py --repo vercel-labs/agent-skills --path web/better-ui web/better-colors --as-plugin` | ||
@@ -50,4 +115,5 @@ ## Behavior and Options | ||
| - Aborts if the destination skill directory already exists. | ||
| - Installs into `$AGENTS_HOME/skills/<skill-name>` (defaults to `~/.agents/skills`). | ||
| - Multiple `--path` values install multiple skills in one run, each named from the path basename unless `--name` is supplied. | ||
| - Installs into `<dimcodeHome>/skills/<skill-name>` (default `~/.dimcode/v2/skills`), which is where Dim keeps the skills it manages. `--shared` installs into `~/.agents/skills` instead, so other agents can see the skill — at the cost of Dim no longer managing its updates. | ||
| - `--as-plugin` installs one plugin into `<dimcodeHome>/plugins/<name>` instead of loose skills; `--plugin-name` overrides the derived name and `--overwrite` replaces an existing plugin of that name. | ||
| - Multiple `--path` values install multiple skills in one run, each named from its `SKILL.md` unless `--name` is supplied. | ||
| - Options: `--ref <ref>` (default `main`), `--dest <path>`, `--method auto|download|git`. | ||
@@ -61,2 +127,3 @@ | ||
| - The skills at https://github.com/openai/skills/tree/main/skills/.system are preinstalled, so no need to help users install those. If they ask, just explain this. If they insist, you can download and overwrite. | ||
| - Installed annotations come from `$AGENTS_HOME/skills`. | ||
| - Installed annotations come from every directory the runtime scans — Dim's own skills dir, the shared `~/.agents/skills`, and skills carried by installed plugins — and match on the `name` in `SKILL.md`, not the directory name. | ||
| - **A name clash aborts the install.** Skills resolve by name, so a second skill under an existing name would never load. The script reports which skill already owns the name and where it lives; the user removes or renames it (or drops it from the install) before retrying. Do not work around this by renaming silently. |
+1
-1
| { | ||
| "name": "dimagent-linux-x64", | ||
| "version": "0.3.12", | ||
| "version": "0.3.15", | ||
| "description": "dimagent binary for Linux x64", | ||
@@ -5,0 +5,0 @@ "os": [ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
554
2.4%58316
1.44%319946330
-0.59%