diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json new file mode 100644 index 0000000..3e464e3 --- /dev/null +++ b/.kimi-plugin/plugin.json @@ -0,0 +1,36 @@ +{ + "name": "ditto", + "version": "0.3.7", + "author": { + "name": "Omni Aura LLC", + "email": "support@heyditto.ai", + "url": "https://heyditto.ai" + }, + "homepage": "https://heyditto.ai", + "repository": "https://github.com/ditto-assistant/ditto-plugins", + "license": "MIT", + "keywords": [ + "ditto", + "heyditto", + "memory", + "recall", + "knowledge-graph", + "mcp" + ], + "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Kimi Code.", + "skills": [ + "./plugins/ditto/skills" + ], + "mcpServers": { + "ditto": { + "transport": "http", + "url": "https://api.heyditto.ai/mcp" + } + }, + "interface": { + "displayName": "Ditto", + "shortDescription": "Recall and save your Ditto memories, curate music, and hand off chats.", + "developerName": "Omni Aura LLC", + "websiteURL": "https://heyditto.ai" + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26d84f8..f4bb6a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,9 @@ host. Every root marketplace lists the same plugins, and out its `skills` field; `agy plugin validate --strict` needs both. - rename the MCP server key to `` in `.mcp.json`, `mcp.json`, `mcp_config.json` and the inline Copilot and Kimi entries. + - Kimi Code installs a GitHub URL only from the repository root, and the root + `.kimi-plugin/plugin.json` serves the `ditto` plugin. Other plugins install in + Kimi from a clone of `plugins/`. 3. **Allow its endpoint.** Add `: ` to `HOSTED_MCP` in `scripts/validate_package.py`. Only hosted HTTP servers are allowed: no commands, headers, env or credentials. diff --git a/README.md b/README.md index dde7b6d..a377092 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ own OAuth flow: no API keys, npm helpers, CLI bootstrap or lifecycle hooks. | Codex | `.agents/plugins/marketplace.json` | `codex plugin marketplace add ditto-assistant/ditto-plugins` then `codex plugin add ditto@ditto-plugins` (or install from `/plugins`) | | GitHub Copilot CLI / VS Code | `.github/plugin/marketplace.json` | `copilot plugin marketplace add ditto-assistant/ditto-plugins` then `copilot plugin install ditto@ditto-plugins` | | Antigravity | `plugins/ditto/.antigravity-plugin/` | `agy plugin install https://github.com/ditto-assistant/ditto-plugins`, or `/plugin install ./plugins/ditto` from a clone | -| Kimi Code | `plugins/ditto/.kimi-plugin/` | Local only: install `plugins/ditto` from a clone (installing from the GitHub URL finds no manifest at the repository root) | +| Kimi Code | `.kimi-plugin/plugin.json` (root, points into `plugins/ditto`) | `/plugins install https://github.com/ditto-assistant/ditto-plugins` in Kimi Code, or install `plugins/ditto` from a clone | ### Claude @@ -112,9 +112,20 @@ Antigravity treats `.mcp.json` as another host's file and from `mcp_config.json` today, and from the portable `mcp.json` once its `enable-agent-plugins-spec` flag is on; both name the same Ditto endpoint. -Kimi Code installs only from a local clone of `plugins/ditto`. Installing from the -GitHub URL looks for a manifest at the repository root, which a multi-plugin -repository does not have. Kimi reads the server from `.kimi-plugin/plugin.json`. +Kimi Code installs straight from GitHub. In a Kimi Code session: + +```text +/plugins install https://github.com/ditto-assistant/ditto-plugins +``` + +Choose **Trust and install**, then `/reload`. `/plugins list` shows Ditto with three +skills and one MCP server, and `/mcp` lists `plugin-ditto:ditto` as needing auth; sign +in with `/mcp-config login plugin-ditto:ditto`. Kimi reads a GitHub URL only from the +repository root, so the root `.kimi-plugin/plugin.json` points its `skills` at +`./plugins/ditto/skills`; it is the plugin's own Kimi manifest with that one path +rebased, and the validator keeps the two identical. To pin a branch or tag, use +`https://github.com/ditto-assistant/ditto-plugins/tree/`. From a clone, install +`./plugins/ditto` instead. The bundled skills are the verified loading path. The hosted MCP server also offers the canonical skill files through `skills/list`, `skills/get` and @@ -145,6 +156,7 @@ the host-labelled sections of [`adapters/ditto-memory.md`](adapters/ditto-memory .grok-plugin/marketplace.json # Grok Build .agents/plugins/marketplace.json # Codex .github/plugin/marketplace.json # GitHub Copilot / VS Code +.kimi-plugin/plugin.json # Kimi Code GitHub installs (ditto, skills rebased into plugins/ditto) adapters/ditto-memory.md # per-host connection guidance (source for the generated skill) plugins// .claude-plugin/plugin.json .cursor-plugin/plugin.json .grok-plugin/plugin.json diff --git a/plugins/ditto/.antigravity-plugin/plugin.json b/plugins/ditto/.antigravity-plugin/plugin.json index d768eee..a6f9c47 100644 --- a/plugins/ditto/.antigravity-plugin/plugin.json +++ b/plugins/ditto/.antigravity-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "ditto", "displayName": "Ditto", - "version": "0.3.6", + "version": "0.3.7", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Antigravity.", "logo": "assets/icon.png", "suggestedPrompts": [ diff --git a/plugins/ditto/.claude-plugin/plugin.json b/plugins/ditto/.claude-plugin/plugin.json index f0b7348..1f036cc 100644 --- a/plugins/ditto/.claude-plugin/plugin.json +++ b/plugins/ditto/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Claude.", "author": { "name": "Omni Aura LLC", diff --git a/plugins/ditto/.codex-plugin/plugin.json b/plugins/ditto/.codex-plugin/plugin.json index a65ac42..c679f03 100644 --- a/plugins/ditto/.codex-plugin/plugin.json +++ b/plugins/ditto/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/plugins/ditto/.cursor-plugin/plugin.json b/plugins/ditto/.cursor-plugin/plugin.json index 9f902a0..72a53bb 100644 --- a/plugins/ditto/.cursor-plugin/plugin.json +++ b/plugins/ditto/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "ditto", "displayName": "Ditto", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Cursor.", - "version": "0.3.6", + "version": "0.3.7", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai" diff --git a/plugins/ditto/.github/plugin/plugin.json b/plugins/ditto/.github/plugin/plugin.json index f5e7b63..620d437 100644 --- a/plugins/ditto/.github/plugin/plugin.json +++ b/plugins/ditto/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "displayName": "Ditto", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through GitHub Copilot.", "author": { diff --git a/plugins/ditto/.grok-plugin/plugin.json b/plugins/ditto/.grok-plugin/plugin.json index 50139ef..c48ff93 100644 --- a/plugins/ditto/.grok-plugin/plugin.json +++ b/plugins/ditto/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/plugins/ditto/.kimi-plugin/plugin.json b/plugins/ditto/.kimi-plugin/plugin.json index ef1f3f3..1ea5d1a 100644 --- a/plugins/ditto/.kimi-plugin/plugin.json +++ b/plugins/ditto/.kimi-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.6", + "version": "0.3.7", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/plugins/ditto/shared-skills.lock.json b/plugins/ditto/shared-skills.lock.json index 02780ef..bbed476 100644 --- a/plugins/ditto/shared-skills.lock.json +++ b/plugins/ditto/shared-skills.lock.json @@ -1,11 +1,11 @@ { "schema_version": 1, "source_repository": "https://github.com/ditto-assistant/ditto-skills", - "source_commit": "9a2a3ee2af084fe2833cec7b093cfd371098f867", + "source_commit": "b65d94a9efaad70f0677550a030bb09ec57c0d91", "host": "plugins", "core_sha256": "5912af5483c469ec15db27858122fc9128fc25cd239202c0fdd9727de7dd5f6b", "adapter_sha256": "7cd895ab3feb0581e7e782de46b8b8220e4281a9ebfe6f6b651753e3a545bac1", - "generator_sha256": "6d23fa054bfa19203783fd7bdf6a6949915a5e62b6c71349af7031d5caae1500", + "generator_sha256": "c3cab1c78bcbd50b81bc80698d87567921326ee9886d220ff61c0d5bd18128fe", "skill_sha256": "dc4a46fc6de30ceac400ced65b122895738757cdcdaf1be529d63bec165e5835", "files_sha256": { "plugins/ditto/skills/curate-music/SKILL.md": "b498f8639aeb64e59f88830ac162a894f9f128b7e1f2db6a7c528092904c1eb4", diff --git a/scripts/shared_skills.py b/scripts/shared_skills.py index 737166b..92e0f34 100644 --- a/scripts/shared_skills.py +++ b/scripts/shared_skills.py @@ -43,6 +43,11 @@ ], } MANIFESTS = {host: f"plugins/{DEFAULT_PLUGIN}/{paths[0]}" for host, paths in PLUGIN_MANIFESTS.items()} +# Repository-root host manifests that point into one plugin, for hosts that only +# install from a repository root (Kimi Code reads /.kimi-plugin/plugin.json +# when given a GitHub URL). A plugin opts in with "root_manifests" in plugins.json; +# they are versioned and bumped with that plugin's own manifests. +ROOT_MANIFESTS = {"plugins": {".kimi-plugin/plugin.json"}} def digest(value): @@ -109,6 +114,13 @@ def plugin_registry(source): names = catalog_names(source, entry["catalog"]) if adapted is not None and names is not None and adapted not in names: raise ValueError("Adapted skill must belong to the plugin catalog") + roots = entry.get("root_manifests", []) + allowed = set().union(*ROOT_MANIFESTS.values()) + if not isinstance(roots, list) or any(not isinstance(r, str) or r not in allowed for r in roots) or len(set(roots)) != len(roots): + raise ValueError("Invalid plugin registry: root_manifests must list known repository-root host manifests") + claimed = [r for entry in data.values() for r in entry.get("root_manifests", [])] + if len(set(claimed)) != len(claimed): + raise ValueError("Invalid plugin registry: a root manifest can point into only one plugin") return data @@ -161,11 +173,19 @@ def additional_files(source, plugin=DEFAULT_PLUGIN, catalog="memory", adapted=DE return result -def bump_manifests(repo, host, plugin): - files = [file_at(repo, f"plugins/{plugin}/{rel}") for rel in PLUGIN_MANIFESTS[host]] - if not files[0].exists(): +def host_manifests(repo, host, plugin, root_manifests=()): + """Repository-relative versioned manifests of one plugin that exist in repo.""" + if any(r not in ROOT_MANIFESTS.get(host, set()) for r in root_manifests): + raise ValueError("Root manifests are not supported for this host") + paths = [f"plugins/{plugin}/{rel}" for rel in PLUGIN_MANIFESTS[host]] + list(root_manifests) + return [p for p in paths if file_at(repo, p).exists()] + + +def bump_manifests(repo, host, plugin, root_manifests=()): + if not file_at(repo, f"plugins/{plugin}/{PLUGIN_MANIFESTS[host][0]}").exists(): raise ValueError("Missing primary host manifest") - present = [(f, json.loads(f.read_text())) for f in files if f.exists()] + files = [file_at(repo, p) for p in host_manifests(repo, host, plugin, root_manifests)] + present = [(f, json.loads(f.read_text())) for f in files] if any("version" not in m for _, m in present): raise ValueError("Every host manifest in one plugin must carry a version before bumping") versions = {m["version"] for _, m in present} @@ -181,7 +201,7 @@ def bump_manifests(repo, host, plugin): return [f.relative_to(Path(repo).resolve()).as_posix() for f, _ in present] -def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog="memory", adapted=DEFAULT_ADAPTED): +def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog="memory", adapted=DEFAULT_ADAPTED, root_manifests=()): paths = layout(plugin, adapted) sha = source_identity(source) generator = file_at(source, GENERATOR).read_bytes() @@ -213,7 +233,7 @@ def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog return False output_changed = skill is not None and (not file_at(repo, paths["output"]).exists() or file_at(repo, paths["output"]).read_bytes() != skill) if bump_version and (output_changed or skill is None and not old or old.get("files_sha256", {}) != extra_hashes): - bump_manifests(repo, host, plugin) + bump_manifests(repo, host, plugin, root_manifests) # A renamed or dropped adapted skill leaves its previous output behind; # old locks without the key used the default adapted skill. if old: @@ -236,12 +256,20 @@ def build(source, repo, host, bump_version=False, plugin=DEFAULT_PLUGIN, catalog def build_all(source, repo, host, bump_version=False): """Build every plugin in the source registry that the distribution hosts.""" changed = {} - for plugin, entry in plugin_registry(source).items(): + registry = plugin_registry(source) + if host == "plugins": + # A root manifest no plugin claims would silently keep its old version. + claimed = {r for entry in registry.values() for r in entry.get("root_manifests", [])} + for root in sorted(ROOT_MANIFESTS[host] - claimed): + if file_at(repo, root).exists(): + raise ValueError(f"{root} exists but no plugin in scripts/plugins.json lists it under root_manifests") + for plugin, entry in registry.items(): if host != "plugins" and plugin != DEFAULT_PLUGIN: continue # single-host repositories carry only the original plugin if not file_at(repo, f"plugins/{plugin}/{PLUGIN_MANIFESTS[host][0]}").exists(): raise ValueError(f"Distribution lacks the {plugin} plugin manifest; scaffold it before generating") - changed[plugin] = build(source, repo, host, bump_version, plugin, entry["catalog"], entry.get("adapted_skill")) + roots = entry.get("root_manifests", []) if host == "plugins" else [] + changed[plugin] = build(source, repo, host, bump_version, plugin, entry["catalog"], entry.get("adapted_skill"), roots) return changed diff --git a/scripts/validate_package.py b/scripts/validate_package.py index e9fe12c..dccba80 100644 --- a/scripts/validate_package.py +++ b/scripts/validate_package.py @@ -28,6 +28,9 @@ "antigravity": ".antigravity-plugin/plugin.json", "kimi": ".kimi-plugin/plugin.json", } +# Hosts that install a GitHub URL only from the repository root get a root +# manifest that points into one plugin. Kimi Code reads /.kimi-plugin/plugin.json. +ROOT_MANIFESTS = {".kimi-plugin/plugin.json": ("kimi", "ditto")} MCP_FILES = {".mcp.json": "type", "mcp.json": "type", "mcp_config.json": None} # Antigravity loads a manifest that declares the portable Agent Plugins schema as # an Agent Plugins package: it then treats .mcp.json and mcp.json as other hosts' @@ -114,6 +117,21 @@ def check_manifest(host, manifest, plugin, folder): raise ValueError(f"{host} manifest must use the shared skills folder") +def check_root_manifest(root, relative, host, plugin): + """A root manifest is the plugin's own host manifest with paths rebased into plugins/.""" + path = root / relative + parts = Path(relative).parts + if any((root / Path(*parts[:i])).is_symlink() for i in range(1, len(parts) + 1)): + raise ValueError("Linked release input") + if not path.is_file(): + raise ValueError(f"Missing root {host} manifest {relative}") + manifest = load(path) + expected = load(root / "plugins" / plugin / MANIFESTS[host]) + expected["skills"] = [f"./plugins/{plugin}/skills"] + if manifest != expected: + raise ValueError(f"Root {relative} must equal plugins/{plugin}/{MANIFESTS[host]} with skills ./plugins/{plugin}/skills") + + def validate(root): root = Path(root) lists = {} @@ -167,6 +185,8 @@ def validate(root): raise ValueError(f"Unexpected release file: {relative}") if not any((folder / "skills").glob("*/SKILL.md")): raise ValueError(f"{plugin} ships no skills") + for relative, (host, plugin) in ROOT_MANIFESTS.items(): + check_root_manifest(root, relative, host, plugin) for name in ("ditto-memory", "curate-music", "handoff"): if not (root / "plugins/ditto/skills" / name / "SKILL.md").is_file(): raise ValueError(f"Missing skill: {name}") diff --git a/tests/test_package.py b/tests/test_package.py index 98e7421..f29655d 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -17,7 +17,7 @@ def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) - for folder in ("plugins", ".claude-plugin", ".cursor-plugin", ".grok-plugin", ".agents", ".github/plugin"): + for folder in ("plugins", ".claude-plugin", ".cursor-plugin", ".grok-plugin", ".agents", ".github/plugin", ".kimi-plugin"): shutil.copytree(ROOT / folder, self.root / folder) def edit(self, relative, change): @@ -78,6 +78,41 @@ def test_portable_mcp_file_declares_its_schema(self): with self.assertRaisesRegex(ValueError, "portable Agent Plugins MCP file"): package.validate(self.root) + def test_root_kimi_manifest_points_into_the_plugin(self): + # Kimi Code installs a GitHub URL from the repository root only (DITTO-1799). + cases = ( + (lambda d: d.__setitem__("skills", ["./skills"]), "skills ./plugins/ditto/skills"), + (lambda d: d.__setitem__("version", "9.9.9"), "must equal"), + (lambda d: d["mcpServers"]["ditto"].__setitem__("url", "https://example.com/mcp"), "must equal"), + ) + for change, message in cases: + with self.subTest(message=message): + self.setUp() + self.edit(".kimi-plugin/plugin.json", change) + with self.assertRaisesRegex(ValueError, message): + package.validate(self.root) + self.setUp() + (self.root / ".kimi-plugin/plugin.json").unlink() + with self.assertRaisesRegex(ValueError, "Missing root kimi manifest"): + package.validate(self.root) + self.setUp() + linked = self.root / ".kimi-plugin/plugin.json" + target = self.root / "kimi-root.json" + target.write_text(linked.read_text()) + linked.unlink() + linked.symlink_to(target) + with self.assertRaisesRegex(ValueError, "Linked release input"): + package.validate(self.root) + self.setUp() + folder = self.root / ".kimi-plugin" + outside = Path(self.tmp.name + "-outside") + shutil.copytree(folder, outside) + self.addCleanup(shutil.rmtree, outside) + shutil.rmtree(folder) + folder.symlink_to(outside, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "Linked release input"): + package.validate(self.root) + def test_other_endpoint_is_rejected(self): f = self.root / "plugins/ditto/.mcp.json" f.write_text(f.read_text().replace("api.heyditto.ai", "example.com"))