@agentprojectcontext/apx 1.59.0 → 1.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/tools/handlers/_asana.js +34 -0
  3. package/src/core/agent/tools/handlers/asana-create-task.js +38 -0
  4. package/src/core/agent/tools/handlers/asana-list-projects.js +19 -0
  5. package/src/core/agent/tools/handlers/asana-list-tasks.js +27 -0
  6. package/src/core/agent/tools/handlers/asana-update-task.js +32 -0
  7. package/src/core/agent/tools/names.js +10 -0
  8. package/src/core/agent/tools/registry.js +11 -0
  9. package/src/core/integrations/catalog.js +66 -0
  10. package/src/core/integrations/index.js +10 -0
  11. package/src/core/integrations/plugins/asana.js +231 -0
  12. package/src/core/integrations/sources.js +56 -0
  13. package/src/core/integrations/store.js +118 -0
  14. package/src/core/stores/project-files.js +21 -2
  15. package/src/host/daemon/api/integrations.js +191 -0
  16. package/src/host/daemon/api.js +2 -0
  17. package/src/interfaces/web/dist/assets/{index-CnQb4N6C.js → index-DFNV6BWh.js} +193 -163
  18. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js.map +1 -0
  19. package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +1 -0
  20. package/src/interfaces/web/dist/index.html +2 -2
  21. package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +275 -0
  22. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +44 -0
  23. package/src/interfaces/web/src/components/integrations/PluginCard.tsx +61 -0
  24. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +39 -0
  25. package/src/interfaces/web/src/lib/api/integrations.ts +106 -0
  26. package/src/interfaces/web/src/lib/api.ts +1 -0
  27. package/src/interfaces/web/src/screens/ProjectScreen.tsx +6 -2
  28. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +146 -0
  29. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +0 -1
  30. package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +0 -1
@@ -0,0 +1,118 @@
1
+ // IntegrationStore: CRUD over one project's integrations.json, plus the
2
+ // cross-scope resolution used by agent tools. Pure filesystem + data logic —
3
+ // no Express, no plugin HTTP calls (those live in ./plugins/*). This keeps the
4
+ // storage layer testable in isolation (see tests/integrations.test.js).
5
+ import {
6
+ readIntegrations,
7
+ writeIntegrations,
8
+ defaultIntegrationsStorage,
9
+ } from "./sources.js";
10
+
11
+ // Keys inside `config` that hold secrets — never sent to the web UI in clear.
12
+ const SECRET_KEYS = new Set([
13
+ "personal_access_token",
14
+ "token",
15
+ "api_key",
16
+ "client_secret",
17
+ ]);
18
+
19
+ // Return a shallow copy of a record with secret config values masked, safe to
20
+ // send to the browser. A present secret becomes `true` under `config.<key>_set`
21
+ // so the UI can show "configured" without leaking the value.
22
+ export function redactRecord(record) {
23
+ if (!record) return record;
24
+ const config = { ...(record.config || {}) };
25
+ for (const key of Object.keys(config)) {
26
+ if (SECRET_KEYS.has(key)) {
27
+ const hasValue = typeof config[key] === "string" && config[key].length > 0;
28
+ delete config[key];
29
+ config[`${key}_set`] = hasValue;
30
+ }
31
+ }
32
+ return { ...record, config };
33
+ }
34
+
35
+ export class IntegrationStore {
36
+ // `storagePath` is a project's ~/.apx/projects/<apxId>/ directory.
37
+ constructor(storagePath) {
38
+ if (!storagePath) throw new Error("IntegrationStore: storagePath required");
39
+ this.storagePath = storagePath;
40
+ }
41
+
42
+ list() {
43
+ return readIntegrations(this.storagePath);
44
+ }
45
+
46
+ get(slug) {
47
+ return this.list().find((r) => r.slug === slug) || null;
48
+ }
49
+
50
+ // Insert or merge a record by slug. `patch` is shallow-merged; `patch.config`
51
+ // is deep-merged one level so callers can update a single config key without
52
+ // clobbering the token. Returns the persisted record.
53
+ upsert(slug, patch = {}) {
54
+ const entries = this.list();
55
+ const idx = entries.findIndex((r) => r.slug === slug);
56
+ const now = new Date().toISOString();
57
+ if (idx === -1) {
58
+ const record = {
59
+ slug,
60
+ name: patch.name || slug,
61
+ type: patch.type || "custom",
62
+ description: patch.description || "",
63
+ source: patch.source || "builtin",
64
+ status: patch.status || "disconnected",
65
+ is_enabled: patch.is_enabled ?? false,
66
+ config: patch.config || {},
67
+ created_at: now,
68
+ updated_at: now,
69
+ };
70
+ entries.push(record);
71
+ writeIntegrations(this.storagePath, entries);
72
+ return record;
73
+ }
74
+ const prev = entries[idx];
75
+ const merged = {
76
+ ...prev,
77
+ ...patch,
78
+ config: { ...(prev.config || {}), ...(patch.config || {}) },
79
+ updated_at: now,
80
+ };
81
+ entries[idx] = merged;
82
+ writeIntegrations(this.storagePath, entries);
83
+ return merged;
84
+ }
85
+
86
+ remove(slug) {
87
+ const entries = this.list();
88
+ const next = entries.filter((r) => r.slug !== slug);
89
+ if (next.length === entries.length) return false;
90
+ writeIntegrations(this.storagePath, next);
91
+ return true;
92
+ }
93
+ }
94
+
95
+ // Resolve the effective integration for `slug` in a project, applying the
96
+ // project→default precedence: a project's OWN enabled record wins; otherwise
97
+ // the default project's enabled record is used. Returns { record, scope } or
98
+ // null when neither is usable. `defaultStorage` defaults to the default
99
+ // project's storage so callers only need the current project's storagePath.
100
+ export function resolveIntegration({
101
+ projectStorage,
102
+ slug,
103
+ defaultStorage = defaultIntegrationsStorage(),
104
+ requireEnabled = true,
105
+ }) {
106
+ const usable = (record) =>
107
+ !!record && (!requireEnabled || (record.is_enabled && record.status === "active"));
108
+
109
+ if (projectStorage) {
110
+ const own = new IntegrationStore(projectStorage).get(slug);
111
+ if (usable(own)) return { record: own, scope: "project", storagePath: projectStorage };
112
+ }
113
+ if (defaultStorage && defaultStorage !== projectStorage) {
114
+ const fallback = new IntegrationStore(defaultStorage).get(slug);
115
+ if (usable(fallback)) return { record: fallback, scope: "global", storagePath: defaultStorage };
116
+ }
117
+ return null;
118
+ }
@@ -23,6 +23,10 @@ const SKIP_DIRS = new Set([
23
23
  "node_modules", ".git", "dist", "build", ".next", ".turbo", ".nuxt",
24
24
  "coverage", ".cache", ".venv", "venv", "__pycache__", ".pytest_cache",
25
25
  ".idea", ".vscode", ".DS_Store",
26
+ // Dependency / build caches from other ecosystems — these dominate the tree
27
+ // (a Composer `vendor/` alone can be tens of thousands of files) and would
28
+ // otherwise exhaust the node budget before the project's own files list.
29
+ "vendor", "bower_components", "Pods", ".terraform", ".gradle",
26
30
  ]);
27
31
 
28
32
  const TEXT_EXTS = new Set([
@@ -103,20 +107,35 @@ function walk(absDir, relPrefix, budget) {
103
107
  dirs.sort((a, b) => a.name.localeCompare(b.name));
104
108
  files.sort((a, b) => a.name.localeCompare(b.name));
105
109
 
110
+ // Two passes so a folder's OWN contents are never hidden by a deep subtree:
111
+ // 1) list every direct child (dirs + files) of this level, then
112
+ // 2) descend into the subdirs. Without this, a huge branch (e.g. `vendor/`)
113
+ // consumed the whole node budget depth-first and the sibling files that
114
+ // sort after the dirs — the project's root files — never got listed.
106
115
  const out = [];
116
+ const pending = []; // dir nodes to recurse into after the direct listing
107
117
  for (const ent of [...dirs, ...files]) {
108
118
  if (budget.count >= MAX_NODES) {
109
119
  budget.truncated = true;
110
- break;
120
+ return out;
111
121
  }
112
122
  budget.count += 1;
113
123
  const rel = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
114
124
  if (ent.isDirectory()) {
115
- out.push({ name: ent.name, path: rel, type: "dir", children: walk(path.join(absDir, ent.name), rel, budget) });
125
+ const node = { name: ent.name, path: rel, type: "dir", children: [] };
126
+ out.push(node);
127
+ pending.push({ node, abs: path.join(absDir, ent.name), rel });
116
128
  } else {
117
129
  out.push({ name: ent.name, path: rel, type: "file", kind: classifyKind(ent.name) });
118
130
  }
119
131
  }
132
+ for (const { node, abs, rel } of pending) {
133
+ if (budget.count >= MAX_NODES) {
134
+ budget.truncated = true;
135
+ break;
136
+ }
137
+ node.children = walk(abs, rel, budget);
138
+ }
120
139
  return out;
121
140
  }
122
141
 
@@ -0,0 +1,191 @@
1
+ // Integration plugins per project. Companion to api/mcps.js: MCP servers are
2
+ // raw tool endpoints; integrations are higher-level plugins (Asana today) that
3
+ // own a credential + lifecycle and expose named tools to agents.
4
+ //
5
+ // Scoping (see core/integrations/store.js): every record lives in one project's
6
+ // integrations.json. `?scope=global` targets the DEFAULT project's store, so
7
+ // "global" integrations are literally the default project's — a project without
8
+ // its own record falls back to that one. This keeps "which Asana runs here?"
9
+ // unambiguous when you have both a base Asana and a per-project Asana.
10
+ //
11
+ // GET /projects/:pid/integrations?scope=project|global list stored (redacted)
12
+ // GET /projects/:pid/integrations/catalog roster + resolved status
13
+ // GET /projects/:pid/integrations/:slug?scope=… one plugin status
14
+ // POST /projects/:pid/integrations/:slug/configure?scope=… save credentials
15
+ // POST /projects/:pid/integrations/:slug/validate?scope=… verify against provider
16
+ // POST /projects/:pid/integrations/:slug/deactivate?scope=… disable
17
+ // POST /projects/:pid/integrations/:slug/action/:action?scope=… plugin read action
18
+ // DELETE /projects/:pid/integrations/:slug?scope=… remove
19
+ import {
20
+ IntegrationStore,
21
+ resolveIntegration,
22
+ redactRecord,
23
+ defaultIntegrationsStorage,
24
+ listCatalog,
25
+ getPluginService,
26
+ } from "#core/integrations/index.js";
27
+
28
+ function normalizeScope(raw) {
29
+ if (!raw) return "project";
30
+ const s = String(raw).toLowerCase();
31
+ if (s === "global" || s === "default") return "global";
32
+ if (s === "project" || s === "shared" || s === "runtime") return "project";
33
+ return null;
34
+ }
35
+
36
+ // Resolve the storagePath for the requested scope. `global` → default project
37
+ // store; `project` → the current project's store.
38
+ function storagePathForScope(scope, p, projects) {
39
+ if (scope === "global") {
40
+ const base = projects.get(0);
41
+ return base?.storagePath || defaultIntegrationsStorage();
42
+ }
43
+ return p.storagePath || null;
44
+ }
45
+
46
+ export function register(app, { projects, project }) {
47
+ // List stored integrations in the chosen scope (secrets redacted).
48
+ app.get("/projects/:pid/integrations", (req, res) => {
49
+ const p = project(req, res);
50
+ if (!p) return;
51
+ const scope = normalizeScope(req.query?.scope);
52
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
53
+ const storagePath = storagePathForScope(scope, p, projects);
54
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
55
+ const records = new IntegrationStore(storagePath).list().map(redactRecord);
56
+ res.json(records);
57
+ });
58
+
59
+ // The full plugin roster with each plugin's resolved status for this project
60
+ // (project record wins over the default/global one).
61
+ app.get("/projects/:pid/integrations/catalog", (req, res) => {
62
+ const p = project(req, res);
63
+ if (!p) return;
64
+ const catalog = listCatalog().map((entry) => {
65
+ const svc = getPluginService(entry.slug);
66
+ let status = { slug: entry.slug, status: "disconnected", is_enabled: false };
67
+ let scope = null;
68
+ if (svc) {
69
+ const resolved = resolveIntegration({
70
+ projectStorage: p.storagePath,
71
+ slug: entry.slug,
72
+ requireEnabled: false,
73
+ });
74
+ status = svc.status(resolved?.record || null);
75
+ scope = resolved?.scope || null;
76
+ }
77
+ return { ...entry, status, resolved_scope: scope };
78
+ });
79
+ res.json(catalog);
80
+ });
81
+
82
+ // Status for a single plugin in the chosen scope.
83
+ app.get("/projects/:pid/integrations/:slug", (req, res) => {
84
+ const p = project(req, res);
85
+ if (!p) return;
86
+ const svc = getPluginService(req.params.slug);
87
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
88
+ const scope = normalizeScope(req.query?.scope);
89
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
90
+ const storagePath = storagePathForScope(scope, p, projects);
91
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
92
+ const record = new IntegrationStore(storagePath).get(req.params.slug);
93
+ res.json(svc.status(record));
94
+ });
95
+
96
+ // Save credentials / config. Creates the record if missing.
97
+ app.post("/projects/:pid/integrations/:slug/configure", (req, res) => {
98
+ const p = project(req, res);
99
+ if (!p) return;
100
+ const svc = getPluginService(req.params.slug);
101
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
102
+ const scope = normalizeScope(req.query?.scope);
103
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
104
+ const storagePath = storagePathForScope(scope, p, projects);
105
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
106
+ const store = new IntegrationStore(storagePath);
107
+ try {
108
+ const { patch } = svc.configure(store.get(req.params.slug), req.body || {});
109
+ const record = store.upsert(req.params.slug, patch);
110
+ res.status(201).json(redactRecord(record));
111
+ } catch (e) {
112
+ res.status(400).json({ error: e.message });
113
+ }
114
+ });
115
+
116
+ // Verify the stored credentials against the provider, then persist the result.
117
+ app.post("/projects/:pid/integrations/:slug/validate", async (req, res) => {
118
+ const p = project(req, res);
119
+ if (!p) return;
120
+ const svc = getPluginService(req.params.slug);
121
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
122
+ const scope = normalizeScope(req.query?.scope);
123
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
124
+ const storagePath = storagePathForScope(scope, p, projects);
125
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
126
+ const store = new IntegrationStore(storagePath);
127
+ const record = store.get(req.params.slug);
128
+ if (!record) return res.status(404).json({ error: "integration not configured" });
129
+ try {
130
+ const { patch, result } = await svc.validate(record);
131
+ store.upsert(req.params.slug, patch);
132
+ if (result && result.ok === false) return res.status(400).json(result);
133
+ res.json(result);
134
+ } catch (e) {
135
+ res.status(400).json({ error: e.message });
136
+ }
137
+ });
138
+
139
+ // Disable a plugin without deleting its stored credentials.
140
+ app.post("/projects/:pid/integrations/:slug/deactivate", (req, res) => {
141
+ const p = project(req, res);
142
+ if (!p) return;
143
+ const svc = getPluginService(req.params.slug);
144
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
145
+ const scope = normalizeScope(req.query?.scope);
146
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
147
+ const storagePath = storagePathForScope(scope, p, projects);
148
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
149
+ const store = new IntegrationStore(storagePath);
150
+ if (!store.get(req.params.slug)) return res.status(404).json({ error: "integration not configured" });
151
+ const { patch } = svc.deactivate(store.get(req.params.slug));
152
+ const record = store.upsert(req.params.slug, patch);
153
+ res.json(svc.status(record));
154
+ });
155
+
156
+ // Plugin-specific read action (e.g. Asana → list workspaces for the token).
157
+ app.post("/projects/:pid/integrations/:slug/action/:action", async (req, res) => {
158
+ const p = project(req, res);
159
+ if (!p) return;
160
+ const svc = getPluginService(req.params.slug);
161
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
162
+ const fn = svc.actions?.[req.params.action];
163
+ if (typeof fn !== "function") {
164
+ return res.status(404).json({ error: `unknown action "${req.params.action}"` });
165
+ }
166
+ const scope = normalizeScope(req.query?.scope);
167
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
168
+ const storagePath = storagePathForScope(scope, p, projects);
169
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
170
+ const record = new IntegrationStore(storagePath).get(req.params.slug);
171
+ if (!record) return res.status(404).json({ error: "integration not configured" });
172
+ try {
173
+ res.json(await fn.call(svc.actions, record));
174
+ } catch (e) {
175
+ res.status(400).json({ error: e.message });
176
+ }
177
+ });
178
+
179
+ // Remove a stored integration entirely.
180
+ app.delete("/projects/:pid/integrations/:slug", (req, res) => {
181
+ const p = project(req, res);
182
+ if (!p) return;
183
+ const scope = normalizeScope(req.query?.scope);
184
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
185
+ const storagePath = storagePathForScope(scope, p, projects);
186
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
187
+ const removed = new IntegrationStore(storagePath).remove(req.params.slug);
188
+ if (!removed) return res.status(404).end();
189
+ res.status(204).end();
190
+ });
191
+ }
@@ -18,6 +18,7 @@ import { register as registerProjects } from "./api/projects.js";
18
18
  import { register as registerAgents } from "./api/agents.js";
19
19
  import { register as registerSessions } from "./api/sessions.js";
20
20
  import { register as registerMcps } from "./api/mcps.js";
21
+ import { register as registerIntegrations } from "./api/integrations.js";
21
22
  import { register as registerVars } from "./api/vars.js";
22
23
  import { register as registerMessages } from "./api/messages.js";
23
24
  import { register as registerTelegram } from "./api/telegram.js";
@@ -109,6 +110,7 @@ export function buildApi({
109
110
  registerAgents(app, ctx);
110
111
  registerSessions(app, ctx);
111
112
  registerMcps(app, ctx);
113
+ registerIntegrations(app, ctx);
112
114
  registerVars(app, ctx);
113
115
  registerMessages(app, ctx);
114
116
  registerEngines(app, ctx);