@agentprojectcontext/apx 1.57.0 → 1.59.0
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.
- package/package.json +1 -1
- package/src/core/agent/skills/index.js +9 -0
- package/src/core/agent/skills/inspector.js +7 -2
- package/src/core/agent/skills/policy.js +128 -0
- package/src/core/agent/super-agent.js +8 -1
- package/src/core/agent/tools/handlers/list-skills.js +6 -3
- package/src/core/agent/tools/handlers/load-skill.js +9 -2
- package/src/core/apc/paths.js +7 -0
- package/src/core/stores/organization.js +152 -0
- package/src/core/stores/project-files.js +199 -0
- package/src/core/stores/tasks.js +36 -3
- package/src/host/daemon/api/agents.js +22 -2
- package/src/host/daemon/api/files-project.js +99 -0
- package/src/host/daemon/api/organization.js +88 -0
- package/src/host/daemon/api/shared.js +7 -0
- package/src/host/daemon/api/skills.js +301 -17
- package/src/host/daemon/api/tasks.js +14 -0
- package/src/host/daemon/api.js +4 -0
- package/src/interfaces/cli/commands/org.js +77 -0
- package/src/interfaces/cli/commands/skills.js +3 -2
- package/src/interfaces/cli/index.js +48 -0
- package/src/interfaces/web/dist/assets/index-CnQb4N6C.js +731 -0
- package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
- package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
- package/src/interfaces/web/src/components/files/FileBrowser.tsx +138 -0
- package/src/interfaces/web/src/components/files/FileTree.tsx +133 -0
- package/src/interfaces/web/src/components/files/FileViewer.tsx +167 -0
- package/src/interfaces/web/src/components/files/MarkdownEditor.tsx +48 -0
- package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +146 -0
- package/src/interfaces/web/src/components/files/NewFileDialog.tsx +66 -0
- package/src/interfaces/web/src/components/settings/SkillsManager.tsx +465 -0
- package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -0
- package/src/interfaces/web/src/components/structure/StructureDialogs.tsx +172 -0
- package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +142 -0
- package/src/interfaces/web/src/components/tasks/taskStatus.tsx +57 -0
- package/src/interfaces/web/src/i18n/en.ts +171 -0
- package/src/interfaces/web/src/i18n/es.ts +171 -0
- package/src/interfaces/web/src/lib/api/organization.ts +18 -0
- package/src/interfaces/web/src/lib/api/projectFiles.ts +19 -0
- package/src/interfaces/web/src/lib/api/skills.ts +79 -8
- package/src/interfaces/web/src/lib/api/tasks.ts +16 -1
- package/src/interfaces/web/src/lib/api.ts +2 -0
- package/src/interfaces/web/src/lib/slug.ts +11 -0
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +25 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
- package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +31 -11
- package/src/interfaces/web/src/screens/project/AgentsTab.tsx +24 -7
- package/src/interfaces/web/src/screens/project/DocsTab.tsx +13 -0
- package/src/interfaces/web/src/screens/project/FilesTab.tsx +12 -0
- package/src/interfaces/web/src/screens/project/Overview.tsx +122 -10
- package/src/interfaces/web/src/screens/project/SkillsTab.tsx +13 -0
- package/src/interfaces/web/src/screens/project/StructureTab.tsx +147 -0
- package/src/interfaces/web/src/screens/project/TasksTab.tsx +101 -62
- package/src/interfaces/web/src/types/daemon.ts +63 -0
- package/src/interfaces/web/dist/assets/index-CEI8DfVg.css +0 -1
- package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js +0 -651
- package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js.map +0 -1
package/package.json
CHANGED
|
@@ -3,6 +3,15 @@ export { condenseSkillDescription, buildSkillsHintBlock } from "./catalog.js";
|
|
|
3
3
|
export { tryResolveSkillCommand } from "./trigger.js";
|
|
4
4
|
export { suggestSkillForPrompt, clearSkillVectorCache } from "./rag.js";
|
|
5
5
|
export { listSkills, loadSkill, SKILL_LOCATIONS } from "./loader.js";
|
|
6
|
+
export {
|
|
7
|
+
isPrivateSkill,
|
|
8
|
+
isSkillEnabled,
|
|
9
|
+
filterEnabledSkills,
|
|
10
|
+
annotateSkills,
|
|
11
|
+
setSkillEnabled,
|
|
12
|
+
resolveScopeKey,
|
|
13
|
+
DEFAULT_SCOPE,
|
|
14
|
+
} from "./policy.js";
|
|
6
15
|
export {
|
|
7
16
|
inspectPromptForSkills,
|
|
8
17
|
isInspectorEnabled,
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { embedOne, cosineSim } from "#core/memory/embeddings.js";
|
|
27
27
|
import { listSkills, loadSkill } from "./loader.js";
|
|
28
|
+
import { filterEnabledSkills, isSkillEnabled } from "./policy.js";
|
|
28
29
|
import { readIndex, backgroundRefreshIfStale } from "./index-store.js";
|
|
29
30
|
|
|
30
31
|
// Defaults — exported so the CLI/web can render them.
|
|
@@ -187,7 +188,8 @@ export async function inspectPromptForSkills({ prompt, projectPath, globalConfig
|
|
|
187
188
|
};
|
|
188
189
|
}
|
|
189
190
|
|
|
190
|
-
const scored = scoreAgainstIndex(probe.vector, items)
|
|
191
|
+
const scored = scoreAgainstIndex(probe.vector, items).filter((s) =>
|
|
192
|
+
isSkillEnabled(s, { config: globalConfig, projectPath }));
|
|
191
193
|
return await pickAndRender({ scored, projectPath, probe, cfg });
|
|
192
194
|
}
|
|
193
195
|
|
|
@@ -196,7 +198,10 @@ export async function inspectPromptForSkills({ prompt, projectPath, globalConfig
|
|
|
196
198
|
// ---------------------------------------------------------------------------
|
|
197
199
|
|
|
198
200
|
async function inspectFromLive({ text, projectPath, cfg, globalConfig, embedOpts }) {
|
|
199
|
-
const skills = listSkills({ projectPath })
|
|
201
|
+
const skills = filterEnabledSkills(listSkills({ projectPath }), {
|
|
202
|
+
config: globalConfig,
|
|
203
|
+
projectPath,
|
|
204
|
+
});
|
|
200
205
|
if (!skills.length) {
|
|
201
206
|
return { contextNote: "", trace: { enabled: true, reason: "no_skills" } };
|
|
202
207
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Skills enable/disable policy — scope-aware gating shared by every consumer.
|
|
2
|
+
//
|
|
3
|
+
// A skill can be turned off per scope. A "scope" is either the super-agent /
|
|
4
|
+
// no-project baseline ("default") or a specific project (keyed by its absolute
|
|
5
|
+
// path). Config lives at:
|
|
6
|
+
//
|
|
7
|
+
// config.skills.policy["default"] = { "<slug>": true|false, ... }
|
|
8
|
+
// config.skills.policy["<projectPath>"] = { "<slug>": true|false, ... }
|
|
9
|
+
//
|
|
10
|
+
// A boolean under a scope is an explicit override; an absent slug inherits.
|
|
11
|
+
// Empty/missing policy = every skill enabled (the pre-feature behavior), so this
|
|
12
|
+
// is fully backward compatible.
|
|
13
|
+
//
|
|
14
|
+
// PRIVATE skills (source "builtin") are APX's own shipped skills. They are
|
|
15
|
+
// always active and can never be disabled or deleted — the UI shows them locked.
|
|
16
|
+
//
|
|
17
|
+
// Effective enabled(skill, projectPath):
|
|
18
|
+
// 1. builtin source → true (private, locked)
|
|
19
|
+
// 2. project scope explicit value → that value
|
|
20
|
+
// 3. "default" scope explicit value→ that value
|
|
21
|
+
// 4. otherwise → true
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_SCOPE = "default";
|
|
24
|
+
|
|
25
|
+
// Sources whose skills ship with apx and are always active.
|
|
26
|
+
const PRIVATE_SOURCES = new Set(["builtin"]);
|
|
27
|
+
|
|
28
|
+
/** True for APX's own built-in skills — always active, never disableable. */
|
|
29
|
+
export function isPrivateSkill(skill) {
|
|
30
|
+
return PRIVATE_SOURCES.has(skill?.source);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Normalize a project path (or nothing) into a policy scope key. */
|
|
34
|
+
export function resolveScopeKey(projectPath) {
|
|
35
|
+
const p = typeof projectPath === "string" ? projectPath.trim() : "";
|
|
36
|
+
return p || DEFAULT_SCOPE;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function policyMap(config) {
|
|
40
|
+
const p = config?.skills?.policy;
|
|
41
|
+
return p && typeof p === "object" ? p : {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function scopeOverride(config, scopeKey, slug) {
|
|
45
|
+
const scope = policyMap(config)[scopeKey];
|
|
46
|
+
if (!scope || typeof scope !== "object") return undefined;
|
|
47
|
+
const v = scope[slug];
|
|
48
|
+
return typeof v === "boolean" ? v : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve whether a skill is enabled for the given scope.
|
|
53
|
+
* @param {{slug:string, source?:string}} skill
|
|
54
|
+
* @param {{config?:object, projectPath?:string}} ctx
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function isSkillEnabled(skill, { config, projectPath } = {}) {
|
|
58
|
+
if (isPrivateSkill(skill)) return true;
|
|
59
|
+
const slug = skill?.slug;
|
|
60
|
+
if (!slug) return true;
|
|
61
|
+
|
|
62
|
+
const scopeKey = resolveScopeKey(projectPath);
|
|
63
|
+
if (scopeKey !== DEFAULT_SCOPE) {
|
|
64
|
+
const own = scopeOverride(config, scopeKey, slug);
|
|
65
|
+
if (own !== undefined) return own;
|
|
66
|
+
}
|
|
67
|
+
const base = scopeOverride(config, DEFAULT_SCOPE, slug);
|
|
68
|
+
if (base !== undefined) return base;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Keep only the skills enabled for the given scope. */
|
|
73
|
+
export function filterEnabledSkills(skills, ctx = {}) {
|
|
74
|
+
if (!Array.isArray(skills)) return [];
|
|
75
|
+
return skills.filter((s) => isSkillEnabled(s, ctx));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Annotate skills for a UI client: adds `enabled`, `private`, and `overridden`
|
|
80
|
+
* (whether *this* scope holds an explicit override, ignoring inheritance).
|
|
81
|
+
*/
|
|
82
|
+
export function annotateSkills(skills, { config, projectPath } = {}) {
|
|
83
|
+
if (!Array.isArray(skills)) return [];
|
|
84
|
+
const scopeKey = resolveScopeKey(projectPath);
|
|
85
|
+
return skills.map((s) => {
|
|
86
|
+
const priv = isPrivateSkill(s);
|
|
87
|
+
const own = priv ? undefined : scopeOverride(config, scopeKey, s.slug);
|
|
88
|
+
return {
|
|
89
|
+
...s,
|
|
90
|
+
private: priv,
|
|
91
|
+
enabled: isSkillEnabled(s, { config, projectPath }),
|
|
92
|
+
overridden: own !== undefined,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Set (or clear) a skill's enabled override for a scope. Mutates and returns the
|
|
99
|
+
* config object. `enabled === null|undefined` clears the override (back to
|
|
100
|
+
* inherit). Private/builtin skills cannot be overridden.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} config the global config (mutated in place)
|
|
103
|
+
* @param {object} args
|
|
104
|
+
* @param {string} args.slug
|
|
105
|
+
* @param {boolean|null} args.enabled
|
|
106
|
+
* @param {string=} args.scope scope key ("default" or a project path)
|
|
107
|
+
* @param {string=} args.projectPath alternative to scope; normalized to a key
|
|
108
|
+
*/
|
|
109
|
+
export function setSkillEnabled(config, { slug, enabled, scope, projectPath } = {}) {
|
|
110
|
+
if (!slug) throw new Error("setSkillEnabled: slug required");
|
|
111
|
+
const scopeKey = scope ? resolveScopeKey(scope) : resolveScopeKey(projectPath);
|
|
112
|
+
|
|
113
|
+
config.skills = config.skills || {};
|
|
114
|
+
config.skills.policy = config.skills.policy || {};
|
|
115
|
+
const map = config.skills.policy;
|
|
116
|
+
|
|
117
|
+
if (enabled === null || enabled === undefined) {
|
|
118
|
+
if (map[scopeKey]) {
|
|
119
|
+
delete map[scopeKey][slug];
|
|
120
|
+
if (Object.keys(map[scopeKey]).length === 0) delete map[scopeKey];
|
|
121
|
+
}
|
|
122
|
+
return config;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
map[scopeKey] = map[scopeKey] || {};
|
|
126
|
+
map[scopeKey][slug] = !!enabled;
|
|
127
|
+
return config;
|
|
128
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Super-agent: daemon-level action agent for Telegram, TUI, desktop, routines.
|
|
2
2
|
import { createToolSession, buildLazyToolsBlock, makeToolHandlers } from "#core/agent/tools/registry.js";
|
|
3
3
|
import { listSkills } from "#core/agent/skills/loader.js";
|
|
4
|
+
import { filterEnabledSkills } from "#core/agent/skills/policy.js";
|
|
4
5
|
import {
|
|
5
6
|
runAgent,
|
|
6
7
|
buildSuperAgentSystem,
|
|
@@ -97,10 +98,16 @@ export async function runSuperAgent({
|
|
|
97
98
|
// noTools callers (summarize/ask) get no session — text only.
|
|
98
99
|
const toolSession = noTools ? null : createToolSession(channel, { allowedTools });
|
|
99
100
|
|
|
101
|
+
// Scope the catalog hint to the skills enabled for this project (or the
|
|
102
|
+
// super-agent baseline when no project). Built-in/private skills always pass.
|
|
103
|
+
const projectPath = channelMeta?.projectPath;
|
|
104
|
+
const scopedListSkills = (opts = {}) =>
|
|
105
|
+
filterEnabledSkills(listSkills(opts), { config: globalConfig, projectPath });
|
|
106
|
+
|
|
100
107
|
const system = buildSuperAgentSystem({
|
|
101
108
|
globalConfig,
|
|
102
109
|
projects,
|
|
103
|
-
listSkills,
|
|
110
|
+
listSkills: scopedListSkills,
|
|
104
111
|
contextNote,
|
|
105
112
|
channel,
|
|
106
113
|
channelMeta,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { listSkills, SKILL_LOCATIONS } from "#core/agent/skills/loader.js";
|
|
2
|
-
import { condenseSkillDescription } from "#core/agent/skills/index.js";
|
|
2
|
+
import { condenseSkillDescription, filterEnabledSkills } from "#core/agent/skills/index.js";
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
5
|
name: "list_skills",
|
|
@@ -20,8 +20,11 @@ export default {
|
|
|
20
20
|
},
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
|
-
makeHandler: () => ({ project_path } = {}) => {
|
|
24
|
-
const skills =
|
|
23
|
+
makeHandler: (ctx = {}) => ({ project_path } = {}) => {
|
|
24
|
+
const skills = filterEnabledSkills(
|
|
25
|
+
listSkills({ projectPath: project_path }),
|
|
26
|
+
{ config: ctx.globalConfig, projectPath: project_path },
|
|
27
|
+
);
|
|
25
28
|
return {
|
|
26
29
|
ok: true,
|
|
27
30
|
count: skills.length,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadSkill } from "#core/agent/skills/loader.js";
|
|
2
|
+
import { isSkillEnabled } from "#core/agent/skills/policy.js";
|
|
2
3
|
|
|
3
4
|
export default {
|
|
4
5
|
name: "load_skill",
|
|
@@ -24,8 +25,14 @@ export default {
|
|
|
24
25
|
},
|
|
25
26
|
},
|
|
26
27
|
},
|
|
27
|
-
makeHandler: () => ({ slug, project_path } = {}) => {
|
|
28
|
+
makeHandler: (ctx = {}) => ({ slug, project_path } = {}) => {
|
|
28
29
|
if (!slug) throw new Error("load_skill: slug required");
|
|
29
|
-
|
|
30
|
+
const skill = loadSkill(slug, { projectPath: project_path });
|
|
31
|
+
if (!isSkillEnabled(skill, { config: ctx.globalConfig, projectPath: project_path })) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`skill "${slug}" is disabled for this scope. Enable it in Settings → Skills, or pick another.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return skill;
|
|
30
37
|
},
|
|
31
38
|
};
|
package/src/core/apc/paths.js
CHANGED
|
@@ -18,6 +18,7 @@ export const APC_SKILLS_DIR = "skills";
|
|
|
18
18
|
export const APC_COMMANDS_DIR = "commands";
|
|
19
19
|
export const APC_NOTES_DIR = "notes";
|
|
20
20
|
export const APC_MCPS_FILE = "mcps.json";
|
|
21
|
+
export const APC_ORGANIZATION_FILE = "organization.json";
|
|
21
22
|
export const APC_REMOVED_FILE = ".removed.json";
|
|
22
23
|
export const AGENTS_MD = "AGENTS.md";
|
|
23
24
|
|
|
@@ -77,6 +78,12 @@ export function apcMcpsFile(root) {
|
|
|
77
78
|
return path.join(root, APC_DIR, APC_MCPS_FILE);
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// Org structure (areas + roles) for company/enterprise projects. Committed,
|
|
82
|
+
// no secrets — travels with the project like agents do.
|
|
83
|
+
export function apcOrganizationFile(root) {
|
|
84
|
+
return path.join(root, APC_DIR, APC_ORGANIZATION_FILE);
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
export function agentsMdFile(root) {
|
|
81
88
|
return path.join(root, AGENTS_MD);
|
|
82
89
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Organization structure (areas + roles) for a project.
|
|
2
|
+
//
|
|
3
|
+
// Only meaningful for "company"/enterprise-shaped projects, but the store is
|
|
4
|
+
// generic: any project can carry an org chart. Persisted as committed JSON at
|
|
5
|
+
// <root>/.apc/organization.json
|
|
6
|
+
// so it travels with the project and is diffable (same model as agents). No
|
|
7
|
+
// secrets ever live here.
|
|
8
|
+
//
|
|
9
|
+
// Shape on disk:
|
|
10
|
+
// {
|
|
11
|
+
// "areas": [{ slug, name, goal }],
|
|
12
|
+
// "roles": [{ slug, name, area, description }]
|
|
13
|
+
// }
|
|
14
|
+
//
|
|
15
|
+
// `role.area` references an area slug (or null for a general/unassigned role).
|
|
16
|
+
// Deleting an area detaches its roles (sets their `area` to null) rather than
|
|
17
|
+
// cascading a delete — losing a role definition because its grouping changed
|
|
18
|
+
// would be surprising.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { apcOrganizationFile } from "../apc/paths.js";
|
|
22
|
+
|
|
23
|
+
export const ORG_SLUG_RE = /^[a-z][a-z0-9_-]*$/;
|
|
24
|
+
|
|
25
|
+
// Derive a slug from a free-text name (kebab-case). Mirrors the front-end's
|
|
26
|
+
// auto-slug behavior so a name typed in either surface yields the same slug.
|
|
27
|
+
export function slugifyName(name) {
|
|
28
|
+
return String(name || "")
|
|
29
|
+
.trim()
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
32
|
+
.replace(/^-+|-+$/g, "")
|
|
33
|
+
.replace(/-{2,}/g, "-");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function emptyOrg() {
|
|
37
|
+
return { areas: [], roles: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readOrganization(root) {
|
|
41
|
+
const file = apcOrganizationFile(root);
|
|
42
|
+
if (!fs.existsSync(file)) return emptyOrg();
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
45
|
+
return {
|
|
46
|
+
areas: Array.isArray(raw.areas) ? raw.areas : [],
|
|
47
|
+
roles: Array.isArray(raw.roles) ? raw.roles : [],
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
// A corrupt file shouldn't take down the whole project view.
|
|
51
|
+
return emptyOrg();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeOrganization(root, org) {
|
|
56
|
+
const file = apcOrganizationFile(root);
|
|
57
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
58
|
+
fs.writeFileSync(file, JSON.stringify(org, null, 2) + "\n");
|
|
59
|
+
return org;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normSlug(input, fallbackName) {
|
|
63
|
+
const raw = input && String(input).trim() ? input : slugifyName(fallbackName);
|
|
64
|
+
const slug = slugifyName(raw);
|
|
65
|
+
if (!slug || !ORG_SLUG_RE.test(slug)) {
|
|
66
|
+
throw new Error(`invalid slug: ${slug || "(empty)"}`);
|
|
67
|
+
}
|
|
68
|
+
return slug;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────── Areas ─────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export function createArea(root, { name, slug, goal } = {}) {
|
|
74
|
+
if (!name || !String(name).trim()) throw new Error("area name required");
|
|
75
|
+
const org = readOrganization(root);
|
|
76
|
+
const areaSlug = normSlug(slug, name);
|
|
77
|
+
if (org.areas.some((a) => a.slug === areaSlug))
|
|
78
|
+
throw new Error(`area ${areaSlug} already exists`);
|
|
79
|
+
const area = { slug: areaSlug, name: String(name).trim(), goal: goal ? String(goal) : null };
|
|
80
|
+
org.areas.push(area);
|
|
81
|
+
writeOrganization(root, org);
|
|
82
|
+
return area;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function updateArea(root, slug, patch = {}) {
|
|
86
|
+
const org = readOrganization(root);
|
|
87
|
+
const area = org.areas.find((a) => a.slug === slug);
|
|
88
|
+
if (!area) return null;
|
|
89
|
+
if (patch.name !== undefined) area.name = String(patch.name).trim();
|
|
90
|
+
if (patch.goal !== undefined) area.goal = patch.goal ? String(patch.goal) : null;
|
|
91
|
+
writeOrganization(root, org);
|
|
92
|
+
return area;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function removeArea(root, slug) {
|
|
96
|
+
const org = readOrganization(root);
|
|
97
|
+
const idx = org.areas.findIndex((a) => a.slug === slug);
|
|
98
|
+
if (idx === -1) return false;
|
|
99
|
+
org.areas.splice(idx, 1);
|
|
100
|
+
// Detach roles that pointed at this area (see header note).
|
|
101
|
+
for (const r of org.roles) if (r.area === slug) r.area = null;
|
|
102
|
+
writeOrganization(root, org);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─────────────────────────────── Roles ─────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export function createRole(root, { name, slug, area, description } = {}) {
|
|
109
|
+
if (!name || !String(name).trim()) throw new Error("role name required");
|
|
110
|
+
const org = readOrganization(root);
|
|
111
|
+
const roleSlug = normSlug(slug, name);
|
|
112
|
+
if (org.roles.some((r) => r.slug === roleSlug))
|
|
113
|
+
throw new Error(`role ${roleSlug} already exists`);
|
|
114
|
+
const areaSlug = area ? String(area) : null;
|
|
115
|
+
if (areaSlug && !org.areas.some((a) => a.slug === areaSlug))
|
|
116
|
+
throw new Error(`area ${areaSlug} not found`);
|
|
117
|
+
const role = {
|
|
118
|
+
slug: roleSlug,
|
|
119
|
+
name: String(name).trim(),
|
|
120
|
+
area: areaSlug,
|
|
121
|
+
description: description ? String(description) : null,
|
|
122
|
+
};
|
|
123
|
+
org.roles.push(role);
|
|
124
|
+
writeOrganization(root, org);
|
|
125
|
+
return role;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function updateRole(root, slug, patch = {}) {
|
|
129
|
+
const org = readOrganization(root);
|
|
130
|
+
const role = org.roles.find((r) => r.slug === slug);
|
|
131
|
+
if (!role) return null;
|
|
132
|
+
if (patch.name !== undefined) role.name = String(patch.name).trim();
|
|
133
|
+
if (patch.description !== undefined)
|
|
134
|
+
role.description = patch.description ? String(patch.description) : null;
|
|
135
|
+
if (patch.area !== undefined) {
|
|
136
|
+
const areaSlug = patch.area ? String(patch.area) : null;
|
|
137
|
+
if (areaSlug && !org.areas.some((a) => a.slug === areaSlug))
|
|
138
|
+
throw new Error(`area ${areaSlug} not found`);
|
|
139
|
+
role.area = areaSlug;
|
|
140
|
+
}
|
|
141
|
+
writeOrganization(root, org);
|
|
142
|
+
return role;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function removeRole(root, slug) {
|
|
146
|
+
const org = readOrganization(root);
|
|
147
|
+
const idx = org.roles.findIndex((r) => r.slug === slug);
|
|
148
|
+
if (idx === -1) return false;
|
|
149
|
+
org.roles.splice(idx, 1);
|
|
150
|
+
writeOrganization(root, org);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Project file browser — a safe, sandboxed view of a project's own files.
|
|
2
|
+
//
|
|
3
|
+
// Powers the web /files browser and the /docs editor (same store, different
|
|
4
|
+
// root). Everything is resolved *inside* the project root; any path that would
|
|
5
|
+
// escape it throws. This is intentionally NOT a general filesystem API — it
|
|
6
|
+
// only ever touches files under one project.
|
|
7
|
+
//
|
|
8
|
+
// Two roots matter:
|
|
9
|
+
// - project root — the repo directory (whole-project /files browser)
|
|
10
|
+
// - docs root — a subfolder (config `docs.root`, default "docs") used by
|
|
11
|
+
// the /docs editor, so specs/casework live in one place
|
|
12
|
+
// (like Appsi's work/ folder of case folders).
|
|
13
|
+
//
|
|
14
|
+
// Reads classify by extension: text is returned inline (utf8); images under a
|
|
15
|
+
// size cap are returned base64 so the authenticated JSON API can render them
|
|
16
|
+
// without a separate unauthenticated asset route; anything else is reported as
|
|
17
|
+
// binary with no body (the UI shows metadata + a download affordance).
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
// Directories that are never useful to browse and would blow the node budget.
|
|
22
|
+
const SKIP_DIRS = new Set([
|
|
23
|
+
"node_modules", ".git", "dist", "build", ".next", ".turbo", ".nuxt",
|
|
24
|
+
"coverage", ".cache", ".venv", "venv", "__pycache__", ".pytest_cache",
|
|
25
|
+
".idea", ".vscode", ".DS_Store",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const TEXT_EXTS = new Set([
|
|
29
|
+
"md", "markdown", "mdx", "txt", "text", "rst", "adoc",
|
|
30
|
+
"js", "jsx", "ts", "tsx", "mjs", "cjs", "json", "jsonc", "json5",
|
|
31
|
+
"css", "scss", "sass", "less", "html", "htm", "xml", "svg", "vue", "svelte",
|
|
32
|
+
"py", "rb", "go", "rs", "java", "kt", "c", "h", "cpp", "hpp", "cc",
|
|
33
|
+
"cs", "php", "swift", "sh", "bash", "zsh", "fish", "sql", "graphql", "gql",
|
|
34
|
+
"yml", "yaml", "toml", "ini", "cfg", "conf", "env", "properties",
|
|
35
|
+
"csv", "tsv", "log", "gitignore", "dockerignore", "dockerfile", "makefile",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"]);
|
|
39
|
+
|
|
40
|
+
const IMAGE_MIME = {
|
|
41
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
|
|
42
|
+
webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon",
|
|
43
|
+
avif: "image/avif",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Guardrails.
|
|
47
|
+
const MAX_NODES = 4000; // total tree entries returned
|
|
48
|
+
const MAX_TEXT_BYTES = 2_000_000; // 2 MB inline text ceiling
|
|
49
|
+
const MAX_IMAGE_BYTES = 5_000_000; // 5 MB base64 image ceiling
|
|
50
|
+
|
|
51
|
+
function extOf(name) {
|
|
52
|
+
const base = name.toLowerCase();
|
|
53
|
+
if (base === "dockerfile" || base === "makefile") return base;
|
|
54
|
+
const dot = base.lastIndexOf(".");
|
|
55
|
+
return dot === -1 ? "" : base.slice(dot + 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function classifyKind(name) {
|
|
59
|
+
const ext = extOf(name);
|
|
60
|
+
if (IMAGE_EXTS.has(ext)) return "image";
|
|
61
|
+
if (ext === "md" || ext === "markdown" || ext === "mdx") return "markdown";
|
|
62
|
+
if (TEXT_EXTS.has(ext)) return "text";
|
|
63
|
+
return "binary";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Resolve `rel` inside `base`, refusing anything that escapes the sandbox.
|
|
67
|
+
function resolveWithin(base, rel = "") {
|
|
68
|
+
const root = path.resolve(base);
|
|
69
|
+
const clean = String(rel || "").replace(/^[/\\]+/, "");
|
|
70
|
+
const abs = path.resolve(root, clean);
|
|
71
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
72
|
+
throw new Error("path escapes the project root");
|
|
73
|
+
}
|
|
74
|
+
return { root, abs };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The docs root for a project (config.docs.root, default "docs"), as a subpath
|
|
78
|
+
// relative to the project root. Sanitized so it can't escape.
|
|
79
|
+
export function docsSubdir(config) {
|
|
80
|
+
const raw = config?.docs?.root;
|
|
81
|
+
const sub = raw && typeof raw === "string" ? raw : "docs";
|
|
82
|
+
return sub.replace(/^[/\\]+/, "").replace(/\.\.(?:[/\\]|$)/g, "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Recursively build a nested tree under `absDir`. Dirs first, then files, each
|
|
86
|
+
// alphabetical. `relPrefix` is the path relative to the sandbox root, which is
|
|
87
|
+
// what callers pass back to read/write.
|
|
88
|
+
function walk(absDir, relPrefix, budget) {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const dirs = [];
|
|
96
|
+
const files = [];
|
|
97
|
+
for (const ent of entries) {
|
|
98
|
+
const name = ent.name;
|
|
99
|
+
if (name.startsWith(".")) continue; // hide dotfiles/dirs
|
|
100
|
+
if (ent.isDirectory() && SKIP_DIRS.has(name)) continue;
|
|
101
|
+
(ent.isDirectory() ? dirs : files).push(ent);
|
|
102
|
+
}
|
|
103
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
104
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
105
|
+
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const ent of [...dirs, ...files]) {
|
|
108
|
+
if (budget.count >= MAX_NODES) {
|
|
109
|
+
budget.truncated = true;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
budget.count += 1;
|
|
113
|
+
const rel = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
|
|
114
|
+
if (ent.isDirectory()) {
|
|
115
|
+
out.push({ name: ent.name, path: rel, type: "dir", children: walk(path.join(absDir, ent.name), rel, budget) });
|
|
116
|
+
} else {
|
|
117
|
+
out.push({ name: ent.name, path: rel, type: "file", kind: classifyKind(ent.name) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// List a tree. `opts.subdir` scopes the sandbox to a subfolder (used for docs).
|
|
124
|
+
// Returns { root, subdir, tree, truncated }. A missing dir yields an empty tree
|
|
125
|
+
// rather than an error — the UI treats that as "nothing here yet".
|
|
126
|
+
export function listTree(projectRoot, opts = {}) {
|
|
127
|
+
const subdir = opts.subdir ? String(opts.subdir) : "";
|
|
128
|
+
const { abs } = resolveWithin(projectRoot, subdir);
|
|
129
|
+
const budget = { count: 0, truncated: false };
|
|
130
|
+
const tree = fs.existsSync(abs) && fs.statSync(abs).isDirectory()
|
|
131
|
+
? walk(abs, "", budget)
|
|
132
|
+
: [];
|
|
133
|
+
return { root: projectRoot, subdir, tree, truncated: budget.truncated };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Read a single file. `opts.subdir` scopes the sandbox (docs). Returns metadata
|
|
137
|
+
// plus the body when it's text/image; binary bodies are omitted.
|
|
138
|
+
export function readFile(projectRoot, relPath, opts = {}) {
|
|
139
|
+
const base = opts.subdir ? path.join(projectRoot, opts.subdir) : projectRoot;
|
|
140
|
+
const { abs } = resolveWithin(base, relPath);
|
|
141
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
|
|
142
|
+
const stat = fs.statSync(abs);
|
|
143
|
+
const name = path.basename(abs);
|
|
144
|
+
const kind = classifyKind(name);
|
|
145
|
+
const common = {
|
|
146
|
+
path: relPath.replace(/^[/\\]+/, ""),
|
|
147
|
+
name,
|
|
148
|
+
kind,
|
|
149
|
+
size: stat.size,
|
|
150
|
+
modified: stat.mtime.toISOString(),
|
|
151
|
+
};
|
|
152
|
+
if (kind === "image") {
|
|
153
|
+
if (stat.size > MAX_IMAGE_BYTES) return { ...common, encoding: "binary", content: null, too_large: true };
|
|
154
|
+
const ext = extOf(name);
|
|
155
|
+
return {
|
|
156
|
+
...common,
|
|
157
|
+
encoding: "base64",
|
|
158
|
+
mime: IMAGE_MIME[ext] || "application/octet-stream",
|
|
159
|
+
content: fs.readFileSync(abs).toString("base64"),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (kind === "binary") {
|
|
163
|
+
return { ...common, encoding: "binary", content: null };
|
|
164
|
+
}
|
|
165
|
+
if (stat.size > MAX_TEXT_BYTES) {
|
|
166
|
+
return { ...common, encoding: "binary", content: null, too_large: true };
|
|
167
|
+
}
|
|
168
|
+
return { ...common, encoding: "utf8", content: fs.readFileSync(abs, "utf8") };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Write (create or overwrite) a text file, creating parent dirs as needed.
|
|
172
|
+
export function writeFile(projectRoot, relPath, content, opts = {}) {
|
|
173
|
+
const base = opts.subdir ? path.join(projectRoot, opts.subdir) : projectRoot;
|
|
174
|
+
const { abs } = resolveWithin(base, relPath);
|
|
175
|
+
if (typeof content !== "string") throw new Error("content must be a string");
|
|
176
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory())
|
|
177
|
+
throw new Error("path is a directory");
|
|
178
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
179
|
+
fs.writeFileSync(abs, content);
|
|
180
|
+
return { ok: true, path: relPath.replace(/^[/\\]+/, ""), bytes: Buffer.byteLength(content, "utf8") };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Create a directory (mkdir -p).
|
|
184
|
+
export function makeDir(projectRoot, relPath, opts = {}) {
|
|
185
|
+
const base = opts.subdir ? path.join(projectRoot, opts.subdir) : projectRoot;
|
|
186
|
+
const { abs } = resolveWithin(base, relPath);
|
|
187
|
+
fs.mkdirSync(abs, { recursive: true });
|
|
188
|
+
return { ok: true, path: relPath.replace(/^[/\\]+/, "") };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Delete a file or directory (recursive). Refuses to delete the sandbox root.
|
|
192
|
+
export function removeEntry(projectRoot, relPath, opts = {}) {
|
|
193
|
+
const base = opts.subdir ? path.join(projectRoot, opts.subdir) : projectRoot;
|
|
194
|
+
const { root, abs } = resolveWithin(base, relPath);
|
|
195
|
+
if (abs === root) throw new Error("refusing to delete the root");
|
|
196
|
+
if (!fs.existsSync(abs)) return false;
|
|
197
|
+
fs.rmSync(abs, { recursive: true, force: true });
|
|
198
|
+
return true;
|
|
199
|
+
}
|