EXTENSION for TreeOS
land-manager
The administrative AI for the entire land. Registers the land:manager mode, which is the conversation mode activated when an admin navigates to the land root (/). The mode gives the AI a comprehensive set of tools for inspecting and managing the land: land-status for a full overview of extensions, users, trees, and federation peers; land-config-read and land-config-set for reading and writing land configuration; land-users and land-peers for listing users and federated peer lands; and land-system-nodes for inspecting the system node tree (.identity, .config, .peers, .extensions). Extension management is a core capability. land-ext-list shows all loaded extensions with version numbers and what they provide (routes, tools, jobs, modes). land-ext-search queries the Horizon registry at horizon.treeos.ai for available extensions. land-ext-install downloads an extension from the registry into the extensions directory. land-ext-disable and land-ext-enable toggle extensions on and off. All write operations are admin-only and require a land restart to take effect. The extension also provides spatial scoping tools (ext-scope-read, ext-scope-set) that are injected into tree modes (librarian and structure) so tree owners can control which extensions are active on their branches. ext-scope-read shows the inheritance chain of blocked and restricted extensions at any node. ext-scope-set writes blocking or restriction rules that inherit to all children. The shell extension's execute-shell tool is included in the land manager mode when available, giving admins direct server access through conversation. Routes expose land-status, land-users, and a chat endpoint for CLI and HTTP access.
v1.0.2 by TreeOS Site 0 downloads 4 files 777 lines 33.0 KB published 38d ago
treeos ext install land-manager
View changelog

Manifest

Provides

  • routes
  • tools
  • 2 CLI commands

Requires

  • models: Node, User

Optional

  • extensions: shell
SHA256: 9664899795a10d2b487f84d6823b061f12e638573597e6171ab20e0756b2697f

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

CLI Commands

CommandMethodDescription
land-statusGETShow land overview (extensions, users, trees, peers)
land-usersGETList all users on this land

Source Code

1import log from "../../seed/log.js";
2import getTools, { setMetadata as setToolMetadata } from "./tools.js";
3import router from "./routes.js";
4
5export async function init(core) {
6  setToolMetadata(core.metadata);
7  // Register a custom mode for land management conversations
8  core.modes.registerMode("land:manager", {
9    emoji: "🏗️",
10    label: "Land Manager",
11    bigMode: "land",
12    toolNames: [
13      "land-status",
14      "land-config-read",
15      "land-config-set",
16      "land-users",
17      "land-peers",
18      "land-system-nodes",
19      "land-ext-list",
20      "land-ext-install",
21      "land-ext-disable",
22      "land-ext-enable",
23      "land-ext-search",
24      "ext-scope-read",
25      "ext-scope-set",
26      "execute-shell",
27    ],
28    buildSystemPrompt({ username }) {
29      return `You are the Land Manager for this TreeOS instance. ${username} is the operator.
30
31You have tools to inspect and manage the land:
32  land-status: overview (extensions, users, trees, peers)
33  land-config-read / land-config-set: read/write land configuration
34  land-users: list users with profile types and tree counts
35  land-peers: list federated peer lands
36  land-system-nodes: inspect system node tree
37
38Extension management:
39  land-ext-list: show loaded extensions and what they provide
40  land-ext-search: search the registry for available extensions
41  land-ext-install: install an extension from the registry (requires restart)
42  land-ext-disable / land-ext-enable: toggle extensions (requires restart)
43
44  execute-shell: run any shell command on the server (use carefully, god-only)
45
46Be direct. Show data. Suggest actions. When asked to do something, use the tools.
47When unsure, check land-status first for context.`;
48    },
49  }, "land-manager");
50
51  log.info("LandManager", "Land manager mode registered (home:land-manager)");
52
53  return {
54    router,
55    tools: getTools(),
56    // Inject scoping tools into tree modes so tree owners can manage extension access
57    modeTools: [
58      { modeKey: "tree:librarian", toolNames: ["ext-scope-read", "ext-scope-set"] },
59      { modeKey: "tree:structure", toolNames: ["ext-scope-read", "ext-scope-set"] },
60    ],
61  };
62}
63
1export default {
2  name: "land-manager",
3  version: "1.0.2",
4  builtFor: "TreeOS",
5  description:
6    "The administrative AI for the entire land. Registers the land:manager mode, which is the " +
7    "conversation mode activated when an admin navigates to the land root (/). The mode gives " +
8    "the AI a comprehensive set of tools for inspecting and managing the land: land-status for " +
9    "a full overview of extensions, users, trees, and federation peers; land-config-read and " +
10    "land-config-set for reading and writing land configuration; land-users and land-peers for " +
11    "listing users and federated peer lands; and land-system-nodes for inspecting the system " +
12    "node tree (.identity, .config, .peers, .extensions).\n\n" +
13    "Extension management is a core capability. land-ext-list shows all loaded extensions with " +
14    "version numbers and what they provide (routes, tools, jobs, modes). land-ext-search queries " +
15    "the Horizon registry at horizon.treeos.ai for available extensions. land-ext-install " +
16    "downloads an extension from the registry into the extensions directory. land-ext-disable " +
17    "and land-ext-enable toggle extensions on and off. All write operations are admin-only and " +
18    "require a land restart to take effect.\n\n" +
19    "The extension also provides spatial scoping tools (ext-scope-read, ext-scope-set) that are " +
20    "injected into tree modes (librarian and structure) so tree owners can control which extensions " +
21    "are active on their branches. ext-scope-read shows the inheritance chain of blocked and " +
22    "restricted extensions at any node. ext-scope-set writes blocking or restriction rules that " +
23    "inherit to all children. The shell extension's execute-shell tool is included in the land " +
24    "manager mode when available, giving admins direct server access through conversation. Routes " +
25    "expose land-status, land-users, and a chat endpoint for CLI and HTTP access.",
26
27  needs: {
28    models: ["Node", "User"],
29  },
30
31  optional: {
32    extensions: ["shell"],
33  },
34
35  provides: {
36    routes: "./routes.js",
37    tools: "./tools.js",
38    jobs: false,
39
40    cli: [
41      { command: "land-status", scope: ["land"], description: "Show land overview (extensions, users, trees, peers)", method: "GET", endpoint: "/land/status" },
42      { command: "land-users", scope: ["land"], description: "List all users on this land", method: "GET", endpoint: "/land/users" },
43    ],
44  },
45};
46
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import Node from "../../seed/models/node.js";
4import User from "../../seed/models/user.js";
5import log from "../../seed/log.js";
6import { sendOk, sendError, ERR, DELETED } from "../../seed/protocol.js";
7import { getUserMeta } from "../../seed/tree/userMetadata.js";
8
9const router = express.Router();
10
11// GET /land/status - land overview (admin only)
12router.get("/land/status", authenticate, async (req, res) => {
13  try {
14    const user = await User.findById(req.userId).select("isAdmin").lean();
15    if (!user?.isAdmin) {
16      return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
17    }
18
19    const { getLoadedManifests, getLoadedExtensionNames } = await import("../../extensions/loader.js");
20    const { getLandIdentity, getLandUrl } = await import("../../canopy/identity.js");
21
22    const land = getLandIdentity();
23    const loaded = getLoadedExtensionNames();
24    const manifests = getLoadedManifests();
25    const userCount = await User.countDocuments({ isRemote: { $ne: true } });
26    const treeCount = await Node.countDocuments({ rootOwner: { $ne: null }, parent: { $ne: DELETED } });
27
28    let peerCount = 0;
29    try {
30      const LandPeer = (await import("../../canopy/models/landPeer.js")).default;
31      peerCount = await LandPeer.countDocuments();
32    } catch (err) { log.debug("LandManager", "Canopy peer count unavailable:", err.message); }
33
34    sendOk(res, {
35      land: { name: land.name, domain: land.domain, url: getLandUrl() },
36      extensions: { count: loaded.length, list: manifests.map(m => ({ name: m.name, version: m.version })) },
37      stats: { users: userCount, trees: treeCount, peers: peerCount },
38    });
39  } catch (err) {
40    log.error("LandManager", "Status error:", err.message);
41    sendError(res, 500, ERR.INTERNAL, err.message);
42  }
43});
44
45// GET /land/users - list users (admin only)
46router.get("/land/users", authenticate, async (req, res) => {
47  try {
48    const user = await User.findById(req.userId).select("isAdmin").lean();
49    if (!user?.isAdmin) {
50      return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
51    }
52
53    const users = await User.find({ isRemote: { $ne: true } })
54      .select("username isAdmin metadata")
55      .lean();
56
57    sendOk(res, {
58      users: users.map(u => {
59        const nav = getUserMeta(u, "nav");
60        return {
61          username: u.username,
62          isAdmin: u.isAdmin || false,
63          trees: Array.isArray(nav.roots) ? nav.roots.length : 0,
64        };
65      }),
66    });
67  } catch (err) {
68    sendError(res, 500, ERR.INTERNAL, err.message);
69  }
70});
71
72// POST /land/chat - land management chat (admin only)
73// POST /land/chat - land management chat (admin only)
74router.post("/land/chat", authenticate, async (req, res) => {
75  try {
76    const user = await User.findById(req.userId).select("isAdmin username").lean();
77    if (!user?.isAdmin) {
78      return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
79    }
80
81    const { message } = req.body;
82    if (!message) return sendError(res, 400, ERR.INVALID_INPUT, "message required");
83
84    const { runChat } = await import("../../seed/llm/conversation.js");
85
86    const { answer, chatId } = await runChat({
87      userId: req.userId,
88      username: user.username,
89      message,
90      mode: "land:manager",
91      res,
92    });
93
94    sendOk(res, { answer, chatId });
95  } catch (err) {
96    log.error("LandManager", "Chat error:", err.message);
97    sendError(res, 500, ERR.INTERNAL, err.message);
98  }
99});
100
101// ── Extension management endpoints ──
102
103// GET /land/extensions - list loaded extensions
104router.get("/land/extensions", authenticate, async (req, res) => {
105  try {
106    const user = await User.findById(req.userId).select("isAdmin").lean();
107    if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
108    const { getLoadedManifests } = await import("../../extensions/loader.js");
109    const { getLandConfigValue } = await import("../../seed/landConfig.js");
110    const manifests = getLoadedManifests();
111    const disabled = getLandConfigValue("disabledExtensions") || [];
112    sendOk(res, { loaded: manifests, count: manifests.length, disabled });
113  } catch (err) {
114    sendError(res, 500, ERR.INTERNAL, err.message);
115  }
116});
117
118// GET /land/extensions/:name - single extension info
119router.get("/land/extensions/:name", authenticate, async (req, res) => {
120  try {
121    const user = await User.findById(req.userId).select("isAdmin").lean();
122    if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
123    const { getLoadedManifests } = await import("../../extensions/loader.js");
124    const manifests = getLoadedManifests();
125    const ext = manifests.find(m => m.name === req.params.name);
126    if (!ext) return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, `Extension "${req.params.name}" not found`);
127    sendOk(res, ext);
128  } catch (err) {
129    sendError(res, 500, ERR.INTERNAL, err.message);
130  }
131});
132
133// POST /land/extensions/:name/disable
134router.post("/land/extensions/:name/disable", authenticate, async (req, res) => {
135  try {
136    const user = await User.findById(req.userId).select("isAdmin").lean();
137    if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
138    const { getLandConfigValue, setLandConfigValue } = await import("../../seed/landConfig.js");
139    const { hasExtension } = await import("../../extensions/loader.js");
140    const name = req.params.name;
141    if (!hasExtension(name)) {
142      return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, `Extension "${name}" is not loaded`);
143    }
144    const disabled = getLandConfigValue("disabledExtensions") || [];
145    if (!disabled.includes(name)) {
146      disabled.push(name);
147      await setLandConfigValue("disabledExtensions", disabled, { internal: true });
148    }
149    sendOk(res, { disabled: name, message: "Restart the land to apply." });
150  } catch (err) {
151    sendError(res, 500, ERR.INTERNAL, err.message);
152  }
153});
154
155// POST /land/extensions/:name/enable
156router.post("/land/extensions/:name/enable", authenticate, async (req, res) => {
157  try {
158    const user = await User.findById(req.userId).select("isAdmin").lean();
159    if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
160    const { getLandConfigValue, setLandConfigValue } = await import("../../seed/landConfig.js");
161    const disabled = getLandConfigValue("disabledExtensions") || [];
162    const name = req.params.name;
163    if (!disabled.includes(name)) {
164      return sendError(res, 400, ERR.INVALID_INPUT, `Extension "${name}" is not disabled`);
165    }
166    const updated = disabled.filter(n => n !== name);
167    await setLandConfigValue("disabledExtensions", updated, { internal: true });
168    sendOk(res, { enabled: name, message: "Restart the land to apply." });
169  } catch (err) {
170    sendError(res, 500, ERR.INTERNAL, err.message);
171  }
172});
173
174export default router;
175
1import { z } from "zod";
2import Node from "../../seed/models/node.js";
3import User from "../../seed/models/user.js";
4import log from "../../seed/log.js";
5import { SYSTEM_ROLE, DELETED } from "../../seed/protocol.js";
6import { setLandConfigValue } from "../../seed/landConfig.js";
7
8let _metadata = null;
9export function setMetadata(metadata) { _metadata = metadata; }
10
11async function requireAdmin(userId) {
12  const user = await User.findById(userId).select("isAdmin").lean();
13  return user?.isAdmin === true;
14}
15
16export default function getTools() {
17  return [
18    {
19      name: "land-status",
20      description: "Get land status: loaded extensions, system nodes, config, connected peers, user count, tree count.",
21      schema: {
22        userId: z.string().describe("Injected by server. Ignore."),
23      },
24      annotations: { readOnlyHint: true },
25      async handler({ userId }) {
26        try {
27          const { getLoadedManifests, getLoadedExtensionNames } = await import("../../extensions/loader.js");
28          const { getLandIdentity, getLandUrl } = await import("../../canopy/identity.js");
29
30          const land = getLandIdentity();
31          const loaded = getLoadedExtensionNames();
32          const manifests = getLoadedManifests();
33          const userCount = await User.countDocuments({ isRemote: { $ne: true } });
34          const treeCount = await Node.countDocuments({ rootOwner: { $ne: null }, parent: { $ne: DELETED } });
35
36          let peerCount = 0;
37          try {
38            const LandPeer = (await import("../../canopy/models/landPeer.js")).default;
39            peerCount = await LandPeer.countDocuments();
40          } catch (err) { log.debug("LandManager", "Canopy peer count unavailable:", err.message); }
41
42          const status = {
43            land: { name: land.name, domain: land.domain, url: getLandUrl() },
44            extensions: { count: loaded.length, loaded: manifests.map(m => `${m.name} v${m.version}`).join(", ") },
45            stats: { users: userCount, trees: treeCount, peers: peerCount },
46          };
47
48          return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
49        } catch (err) {
50          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
51        }
52      },
53    },
54
55    {
56      name: "land-config-read",
57      description:
58        "Read land configuration. Shows every config key, its effective value, the default, " +
59        "and whether it has been customized. Omit key for the full system overview.",
60      schema: {
61        key: z.string().optional().describe("Config key to read. Omit for full system config."),
62        userId: z.string().describe("Injected by server. Ignore."),
63      },
64      annotations: { readOnlyHint: true },
65      async handler({ key, userId }) {
66        try {
67          const { getConfigWithDefaults, getLandConfigValue, CONFIG_DEFAULTS } = await import("../../seed/landConfig.js");
68
69          if (key) {
70            const value = getLandConfigValue(key);
71            const def = CONFIG_DEFAULTS[key];
72            const isCustom = value !== null && value !== undefined;
73            return { content: [{ type: "text", text: `${key} = ${JSON.stringify(value ?? def ?? null)}${isCustom ? " (custom)" : " (default)"}` }] };
74          }
75
76          // Full config overview: group by category for readability
77          const full = getConfigWithDefaults();
78          const lines = [];
79          for (const [k, info] of Object.entries(full)) {
80            const val = JSON.stringify(info.value);
81            const tag = info.custom ? "*" : " ";
82            lines.push(`${tag} ${k} = ${val}`);
83          }
84          const header = "Full system configuration (* = custom, space = default):\n";
85          return { content: [{ type: "text", text: header + lines.join("\n") }] };
86        } catch (err) {
87          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
88        }
89      },
90    },
91
92    {
93      name: "land-config-set",
94      description: "Set a land configuration value. God-tier only.",
95      schema: {
96        key: z.string().describe("Config key"),
97        value: z.string().describe("Value to set"),
98        userId: z.string().describe("Injected by server. Ignore."),
99      },
100      annotations: { readOnlyHint: false, destructiveHint: true },
101      async handler({ key, value, userId }) {
102        if (!await requireAdmin(userId)) {
103          return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
104        }
105        try {
106          await setLandConfigValue(key, value);
107          log.info("LandManager", `Config set: ${key} = ${value} (by ${userId})`);
108          return { content: [{ type: "text", text: `Set ${key} = ${value}` }] };
109        } catch (err) {
110          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
111        }
112      },
113    },
114
115    {
116      name: "land-users",
117      description: "List users on this land with their profile type and tree count.",
118      schema: {
119        userId: z.string().describe("Injected by server. Ignore."),
120      },
121      annotations: { readOnlyHint: true },
122      async handler({ userId }) {
123        if (!await requireAdmin(userId)) {
124          return { content: [{ type: "text", text: "Permission denied." }] };
125        }
126        try {
127          const users = await User.find({ isRemote: { $ne: true } }).select("username isAdmin metadata").lean();
128          const { getUserMeta } = await import("../../seed/tree/userMetadata.js");
129          const lines = users.map(u => {
130            const nav = getUserMeta(u, "nav");
131            const treeCount = Array.isArray(nav.roots) ? nav.roots.length : 0;
132            return `${u.username} (${u.isAdmin ? "admin" : "user"}) . ${treeCount} trees`;
133          });
134          return { content: [{ type: "text", text: lines.join("\n") || "No users." }] };
135        } catch (err) {
136          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
137        }
138      },
139    },
140
141    {
142      name: "land-peers",
143      description: "List federated peers connected to this land.",
144      schema: {
145        userId: z.string().describe("Injected by server. Ignore."),
146      },
147      annotations: { readOnlyHint: true },
148      async handler({ userId }) {
149        try {
150          const LandPeer = (await import("../../canopy/models/landPeer.js")).default;
151          const peers = await LandPeer.find().lean();
152          if (!peers.length) return { content: [{ type: "text", text: "No peers." }] };
153          const lines = peers.map(p => `${p.domain} . ${p.status || "unknown"} . last seen ${p.lastSeenAt ? new Date(p.lastSeenAt).toLocaleString() : "never"}`);
154          return { content: [{ type: "text", text: lines.join("\n") }] };
155        } catch (err) {
156          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
157        }
158      },
159    },
160
161    {
162      name: "land-system-nodes",
163      description: "Read the system node tree (.identity, .config, .peers, .extensions).",
164      schema: {
165        userId: z.string().describe("Injected by server. Ignore."),
166      },
167      annotations: { readOnlyHint: true },
168      async handler({ userId }) {
169        try {
170          const systemNodes = await Node.find({ systemRole: { $ne: null } }).select("name systemRole children metadata").lean();
171          const result = systemNodes.map(n => ({
172            name: n.name,
173            role: n.systemRole,
174            children: n.children?.length || 0,
175            metadata: Object.keys(n.metadata instanceof Map ? Object.fromEntries(n.metadata) : (n.metadata || {})),
176          }));
177          return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
178        } catch (err) {
179          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
180        }
181      },
182    },
183    // ── Extension Management ──
184
185    {
186      name: "land-ext-list",
187      description: "List ALL loaded extensions with version and what they provide. Always show the complete list. Never truncate.",
188      schema: {
189        userId: z.string().describe("Injected by server. Ignore."),
190      },
191      annotations: { readOnlyHint: true },
192      async handler({ userId }) {
193        try {
194          const { getLoadedManifests, getDisabledExtensions } = await import("../../extensions/loader.js");
195          const manifests = getLoadedManifests();
196          const disabled = getDisabledExtensions?.() || [];
197          // Compact format so the AI doesn't feel pressured to truncate
198          const lines = manifests.map(m => {
199            const parts = [];
200            if (m.provides?.routes) parts.push("R");
201            if (m.provides?.tools) parts.push("T");
202            if (m.provides?.jobs) parts.push("J");
203            if (m.provides?.modes) parts.push("M");
204            const provides = parts.length ? `[${parts.join("")}]` : "";
205            return `${m.name} ${m.version} ${provides}`;
206          });
207          const header = `${manifests.length} extensions loaded${disabled.length ? ` (${disabled.length} disabled: ${disabled.join(", ")})` : ""}:`;
208          return { content: [{ type: "text", text: `${header}\n${lines.join("\n")}` }] };
209        } catch (err) {
210          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
211        }
212      },
213    },
214
215    {
216      name: "land-ext-install",
217      description: "Install an extension from the registry. Downloads files to extensions/ directory. God-tier only. Requires restart.",
218      schema: {
219        name: z.string().describe("Extension name to install"),
220        userId: z.string().describe("Injected by server. Ignore."),
221      },
222      annotations: { readOnlyHint: false, destructiveHint: true },
223      async handler({ name: extName, userId }) {
224        if (!await requireAdmin(userId)) {
225          return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
226        }
227        try {
228          const { installExtension } = await import("../../extensions/loader.js");
229          const result = await installExtension(extName);
230          return { content: [{ type: "text", text: `Installed ${extName} v${result.version || "?"}. Restart the land to load it.` }] };
231        } catch (err) {
232          return { content: [{ type: "text", text: `Install failed: ${err.message}` }] };
233        }
234      },
235    },
236
237    {
238      name: "land-ext-upgrade",
239      description: "Upgrade an installed extension to the latest version from the registry. Compares installed version to registry, downloads if newer. God-tier only. Requires restart.",
240      schema: {
241        name: z.string().describe("Extension name to upgrade"),
242        userId: z.string().describe("Injected by server. Ignore."),
243      },
244      annotations: { readOnlyHint: false, destructiveHint: true },
245      async handler({ name: extName, userId }) {
246        if (!await requireAdmin(userId)) {
247          return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
248        }
249        try {
250          const { getLoadedManifests, installExtension } = await import("../../extensions/loader.js");
251          const manifests = getLoadedManifests();
252          const current = manifests.find(m => m.name === extName);
253          if (!current) {
254            return { content: [{ type: "text", text: `"${extName}" is not installed. Use land-ext-install instead.` }] };
255          }
256
257          const { getLandConfigValue } = await import("../../seed/landConfig.js");
258          const horizonUrl = getLandConfigValue("HORIZON_URL") || process.env.HORIZON_URL || "https://horizon.treeos.ai";
259          const res = await fetch(`${horizonUrl}/extensions/${encodeURIComponent(extName)}`);
260          if (!res.ok) {
261            return { content: [{ type: "text", text: `Registry lookup failed: HTTP ${res.status}` }] };
262          }
263          const data = await res.json();
264          const latest = data.latest || data;
265          if (!latest?.version) {
266            return { content: [{ type: "text", text: `"${extName}" not found in registry.` }] };
267          }
268
269          // Compare versions
270          const pa = String(current.version).match(/^(\d+)\.(\d+)\.(\d+)/);
271          const pb = String(latest.version).match(/^(\d+)\.(\d+)\.(\d+)/);
272          if (pa && pb) {
273            let isNewer = false;
274            for (let i = 1; i <= 3; i++) {
275              if (Number(pb[i]) > Number(pa[i])) { isNewer = true; break; }
276              if (Number(pb[i]) < Number(pa[i])) break;
277            }
278            if (!isNewer) {
279              return { content: [{ type: "text", text: `${extName} is already at v${current.version} (latest: v${latest.version}). No upgrade needed.` }] };
280            }
281          }
282
283          const result = await installExtension(extName, latest.version);
284          return { content: [{ type: "text", text: `Upgraded ${extName}: v${current.version} -> v${result.version}. Restart the land to load the new version.` }] };
285        } catch (err) {
286          return { content: [{ type: "text", text: `Upgrade failed: ${err.message}` }] };
287        }
288      },
289    },
290
291    {
292      name: "land-ext-check",
293      description: "Check all installed extensions against the registry for available updates. Read-only. No changes made.",
294      schema: {
295        userId: z.string().describe("Injected by server. Ignore."),
296      },
297      annotations: { readOnlyHint: true },
298      async handler({ userId }) {
299        try {
300          const { getLoadedManifests } = await import("../../extensions/loader.js");
301          const { getLandConfigValue } = await import("../../seed/landConfig.js");
302          const horizonUrl = getLandConfigValue("HORIZON_URL") || process.env.HORIZON_URL || "https://horizon.treeos.ai";
303          const manifests = getLoadedManifests();
304
305          // Batch fetch: get all extensions from registry
306          let registryExts = [];
307          try {
308            const res = await fetch(`${horizonUrl}/extensions?limit=100`);
309            if (res.ok) {
310              const data = await res.json();
311              registryExts = data.extensions || data || [];
312            }
313          } catch { /* registry unreachable */ }
314
315          if (registryExts.length === 0) {
316            return { content: [{ type: "text", text: "Could not reach the extension registry. Check HORIZON_URL." }] };
317          }
318
319          const registryMap = new Map();
320          for (const ext of registryExts) {
321            if (!registryMap.has(ext.name) || ext.version > registryMap.get(ext.name)) {
322              registryMap.set(ext.name, ext.version);
323            }
324          }
325
326          const updates = [];
327          const current = [];
328          const notInRegistry = [];
329
330          for (const m of manifests) {
331            const registryVersion = registryMap.get(m.name);
332            if (!registryVersion) {
333              notInRegistry.push(m.name);
334              continue;
335            }
336            const pa = String(m.version).match(/^(\d+)\.(\d+)\.(\d+)/);
337            const pb = String(registryVersion).match(/^(\d+)\.(\d+)\.(\d+)/);
338            if (pa && pb) {
339              let isNewer = false;
340              for (let i = 1; i <= 3; i++) {
341                if (Number(pb[i]) > Number(pa[i])) { isNewer = true; break; }
342                if (Number(pb[i]) < Number(pa[i])) break;
343              }
344              if (isNewer) {
345                updates.push({ name: m.name, installed: m.version, available: registryVersion });
346              } else {
347                current.push(m.name);
348              }
349            }
350          }
351
352          if (updates.length === 0) {
353            return { content: [{ type: "text", text: `All ${current.length} registered extensions are up to date.${notInRegistry.length > 0 ? ` ${notInRegistry.length} local-only (not in registry).` : ""}` }] };
354          }
355
356          const lines = updates.map(u => `${u.name}: v${u.installed} -> v${u.available}`);
357          return { content: [{ type: "text", text: `${updates.length} update(s) available:\n${lines.join("\n")}\n\nUse land-ext-upgrade <name> to upgrade.` }] };
358        } catch (err) {
359          return { content: [{ type: "text", text: `Check failed: ${err.message}` }] };
360        }
361      },
362    },
363
364    {
365      name: "land-ext-disable",
366      description: "Disable an extension. It won't load on next restart. Data stays. God-tier only.",
367      schema: {
368        name: z.string().describe("Extension name to disable"),
369        userId: z.string().describe("Injected by server. Ignore."),
370      },
371      annotations: { readOnlyHint: false, destructiveHint: false },
372      async handler({ name: extName, userId }) {
373        if (!await requireAdmin(userId)) {
374          return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
375        }
376        try {
377          const { disableExtension } = await import("../../extensions/loader.js");
378          disableExtension(extName);
379          return { content: [{ type: "text", text: `Disabled ${extName}. Restart to take effect.` }] };
380        } catch (err) {
381          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
382        }
383      },
384    },
385
386    {
387      name: "land-ext-enable",
388      description: "Re-enable a disabled extension. God-tier only. Requires restart.",
389      schema: {
390        name: z.string().describe("Extension name to enable"),
391        userId: z.string().describe("Injected by server. Ignore."),
392      },
393      annotations: { readOnlyHint: false, destructiveHint: false },
394      async handler({ name: extName, userId }) {
395        if (!await requireAdmin(userId)) {
396          return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
397        }
398        try {
399          const { enableExtension } = await import("../../extensions/loader.js");
400          enableExtension(extName);
401          return { content: [{ type: "text", text: `Enabled ${extName}. Restart to take effect.` }] };
402        } catch (err) {
403          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
404        }
405      },
406    },
407
408    {
409      name: "land-ext-search",
410      description: "Search the extension registry for available extensions to install.",
411      schema: {
412        query: z.string().optional().describe("Search query. Omit for all."),
413        userId: z.string().describe("Injected by server. Ignore."),
414      },
415      annotations: { readOnlyHint: true },
416      async handler({ query, userId }) {
417        try {
418          const { getLandConfigValue } = await import("../../seed/landConfig.js");
419          const horizonUrl = getLandConfigValue("HORIZON_URL") || "https://horizon.treeos.ai";
420          const url = query ? `${horizonUrl}/extensions?q=${encodeURIComponent(query)}` : `${horizonUrl}/extensions`;
421          const res = await fetch(url);
422          const data = await res.json();
423          const exts = data.extensions || data || [];
424          if (!exts.length) return { content: [{ type: "text", text: "No extensions found." }] };
425          const lines = exts.map(e => `${e.name} v${e.version} . ${e.description || ""}`);
426          return { content: [{ type: "text", text: lines.join("\n") }] };
427        } catch (err) {
428          return { content: [{ type: "text", text: `Registry error: ${err.message}` }] };
429        }
430      },
431    },
432    // ── Extension Scoping Tools ──
433
434    {
435      name: "ext-scope-read",
436      description: "Show which extensions are blocked or restricted at a node. Returns active, blocked, restricted, and inheritance chain.",
437      schema: {
438        nodeId: z.string().describe("Node ID to check extension scope at"),
439        userId: z.string().describe("Injected by server. Ignore."),
440      },
441      annotations: { readOnlyHint: true },
442      async handler({ nodeId, userId }) {
443        try {
444          const { getBlockedExtensionsAtNode } = await import("../../seed/tree/extensionScope.js");
445          const { getLoadedExtensionNames } = await import("../../extensions/loader.js");
446          const { blocked, restricted } = await getBlockedExtensionsAtNode(nodeId);
447          const installed = getLoadedExtensionNames();
448          const active = installed.filter(e => !blocked.has(e));
449          const restrictedObj = Object.fromEntries(restricted);
450          return { content: [{ type: "text", text: JSON.stringify({ nodeId, active, blocked: [...blocked], restricted: restrictedObj, installed }, null, 2) }] };
451        } catch (err) {
452          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
453        }
454      },
455    },
456
457    {
458      name: "ext-scope-set",
459      description: "Block or restrict extensions at a node. Inherits to all children. Use to control what extensions can do on specific branches.",
460      schema: {
461        nodeId: z.string().describe("Node ID to set extension scope on"),
462        blocked: z.array(z.string()).optional().describe("Extensions to fully block (no tools, hooks, modes, metadata)"),
463        restricted: z.record(z.string(), z.string()).optional().describe("Extensions to restrict. e.g. { \"food\": \"read\" } for read-only tools"),
464        userId: z.string().describe("Injected by server. Ignore."),
465      },
466      annotations: { readOnlyHint: false, destructiveHint: false },
467      async handler({ nodeId, blocked, restricted, userId }) {
468        try {
469          const node = await Node.findById(nodeId);
470          if (!node) return { content: [{ type: "text", text: "Node not found" }] };
471
472          const { clearScopeCache } = await import("../../seed/tree/extensionScope.js");
473
474          const config = {};
475          if (blocked?.length) config.blocked = blocked;
476          if (restricted && Object.keys(restricted).length) config.restricted = restricted;
477
478          if (Object.keys(config).length === 0) {
479            await _metadata.setExtMeta(node, "extensions", null);
480          } else {
481            await _metadata.setExtMeta(node, "extensions", config);
482          }
483          clearScopeCache();
484
485          return { content: [{ type: "text", text: `Extension scope updated on "${node.name}". ${config.blocked?.length ? `Blocked: ${config.blocked.join(", ")}. ` : ""}${config.restricted ? `Restricted: ${JSON.stringify(config.restricted)}. ` : ""}Inherits to all children.` }] };
486        } catch (err) {
487          return { content: [{ type: "text", text: `Error: ${err.message}` }] };
488        }
489      },
490    },
491  ];
492}
493

Versions

Version Published Downloads
1.0.2 38d ago 0
1.0.1 48d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star land-manager

Comments

Loading comments...

Post comments from the CLI: treeos ext comment land-manager "your comment"
Max 3 comments per extension. One star and one flag per user.