spectoflow 0.23.5 → 0.27.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/README.md +49 -16
- package/bin/postinstall.js +1 -0
- package/bin/spectoflow.js +226 -35
- package/lib/adapters.js +8 -2
- package/{templates/lib → lib}/custom-dashboard.js +2 -2
- package/{templates/lib → lib}/customize-prompts.js +1 -1
- package/lib/dashboard/connector.js +193 -0
- package/lib/dashboard/handlers.js +53 -0
- package/lib/{hub-server.js → dashboard/hub-server.js} +134 -51
- package/lib/dashboard/inject-design.js +41 -0
- package/lib/dashboard/meeting.js +116 -0
- package/lib/dashboard/ops.js +257 -0
- package/{templates → lib}/dashboard/orchestrator.js +17 -8
- package/{templates → lib}/dashboard/public/app.js +743 -124
- package/{templates → lib}/dashboard/public/charts.js +3 -3
- package/lib/dashboard/public/commands.js +77 -0
- package/{templates → lib}/dashboard/public/designs/console.css +7 -19
- package/{templates → lib}/dashboard/public/designs/orbit.css +3 -4
- package/{templates → lib}/dashboard/public/designs.js +2 -2
- package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
- package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
- package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
- package/{templates → lib}/dashboard/public/hub.html +1 -1
- package/{templates → lib}/dashboard/public/hub.js +27 -4
- package/{templates → lib}/dashboard/public/i18n.js +60 -18
- package/{templates → lib}/dashboard/public/icons.js +2 -0
- package/{templates → lib}/dashboard/public/index.html +106 -13
- package/{templates → lib}/dashboard/public/styles.css +161 -26
- package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
- package/lib/dashboard/routes.js +38 -0
- package/{templates → lib}/dashboard/runner.js +13 -7
- package/{templates → lib}/dashboard/summarize.js +1 -1
- package/lib/detect.js +16 -1
- package/lib/global-config.js +65 -0
- package/lib/init.js +10 -4
- package/lib/registry.js +8 -8
- package/{templates/lib → lib}/store.js +16 -12
- package/lib/update.js +69 -1
- package/lib/workspace.js +119 -0
- package/package.json +3 -3
- package/templates/AGENTS.md +6 -6
- package/templates/README.md +4 -3
- package/templates/agents/framework-curator.md +11 -11
- package/templates/capabilities.md +1 -1
- package/templates/config.json +23 -0
- package/templates/dashboards/.gitkeep +0 -0
- package/templates/skills/generate-dashboard/SKILL.md +15 -13
- package/templates/dashboard/custom/.gitkeep +0 -3
- package/templates/dashboard/handlers.js +0 -251
- package/templates/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- package/templates/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- package/templates/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
- package/templates/dashboard/server.js +0 -73
- package/templates/lib/agents-registry.js +0 -65
- /package/{templates → lib}/dashboard/files.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
- /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
- /package/{templates → lib}/dashboard/public/stats.js +0 -0
|
@@ -28,11 +28,10 @@ project's own team will use to build product features.
|
|
|
28
28
|
## Operating standards
|
|
29
29
|
|
|
30
30
|
- **Declarative dashboards, never raw markup (see `generate-dashboard`).** A custom dashboard is
|
|
31
|
-
produced as a block spec chosen from the framework's fixed vocabulary
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
arbitrary generated code ever executing in the dashboard.
|
|
31
|
+
produced as a block spec chosen from the framework's fixed vocabulary, rendered by the exact same
|
|
32
|
+
token-driven components the built-in Board uses. Why: this is what guarantees a generated dashboard
|
|
33
|
+
matches the *active* design and every future one the user switches to, with zero page-specific CSS
|
|
34
|
+
to keep in sync, and no arbitrary generated code ever executing in the dashboard.
|
|
36
35
|
- **Gold-standard shape for skills and agents (`docs/agents-skills-standard.md`).** A generated
|
|
37
36
|
`SKILL.md` or agent `.md` follows the exact same front-matter and heading structure as every
|
|
38
37
|
shipped one — `## When to use` / `## Method` / `## Output contract` / `## Quality bar` /
|
|
@@ -61,19 +60,19 @@ A generated dashboard renders correctly in every shipped design (light and dark)
|
|
|
61
60
|
hardcoded color or manual style — verified by construction, since only the declarative block
|
|
62
61
|
vocabulary was used. A generated skill or agent passes the same quality bar the framework's own
|
|
63
62
|
shipped files are held to: real citations in `## References`, a checkable `## Quality bar` /
|
|
64
|
-
`## Definition of done`, and front-matter that
|
|
63
|
+
`## Definition of done`, and front-matter that the dashboard's flat parser can read
|
|
65
64
|
unchanged. The new dashboard tab, skill, or agent is visible in the dashboard (Board's nav / Agents &
|
|
66
65
|
Skills tab) on the very next SSE tick — no manual refresh, no extra registration step.
|
|
67
66
|
|
|
68
67
|
## Handoff
|
|
69
68
|
|
|
70
|
-
Writes the generated file(s) directly (`.spectoflow/
|
|
69
|
+
Writes the generated file(s) directly (`.spectoflow/dashboards/<id>.json`,
|
|
71
70
|
`.spectoflow/skills/<slug>/SKILL.md`, or `.spectoflow/agents/<slug>.md`) and reports through the
|
|
72
71
|
`::spectoflow` sentinel (see each skill's Output contract for its exact syntax) so the requester sees
|
|
73
72
|
it land in the group chat and the dashboard picks it up live. A dashboard spec that fails
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
`spectoflow dashboard validate`, or a skill/agent file whose front-matter the flat parser can't read,
|
|
74
|
+
is not done — fix it before reporting completion, never leave a broken file for the dashboard to
|
|
75
|
+
silently skip.
|
|
77
76
|
|
|
78
77
|
## Guardrails
|
|
79
78
|
|
|
@@ -90,5 +89,6 @@ dashboard to silently skip.
|
|
|
90
89
|
## References
|
|
91
90
|
|
|
92
91
|
- `docs/agents-skills-standard.md` — the gold-standard shape this role's output must match.
|
|
93
|
-
-
|
|
92
|
+
- `spectoflow dashboard validate <file>` — the declarative block vocabulary's validator (in the
|
|
93
|
+
spectoflow package).
|
|
94
94
|
- `.spectoflow/skills/clarify` — the reflex this role leans on before generating from an ambiguous ask.
|
|
@@ -17,7 +17,7 @@ the Clarify step in `AGENTS.md`.
|
|
|
17
17
|
`customization` is also **not a workflow step** — it is triggered explicitly, either from the
|
|
18
18
|
dashboard's Settings → Customize page or by a direct request ("add a dashboard for…", "create a skill
|
|
19
19
|
for…", "create an agent for…"). The `framework-curator` agent owns it, running one of four skills:
|
|
20
|
-
`generate-dashboard` (a declarative block-spec page —
|
|
20
|
+
`generate-dashboard` (a declarative block-spec page — validated by `spectoflow dashboard validate`),
|
|
21
21
|
`generate-skill`, `generate-agent` (both follow `docs/agents-skills-standard.md`'s gold-standard
|
|
22
22
|
shape, grounded in real, cited domain standards), and `propose-customizations` (the "Auto" mode:
|
|
23
23
|
analyzes the project and proposes candidates instead of taking a description). Still gated by mode
|
package/templates/config.json
CHANGED
|
@@ -7,6 +7,29 @@
|
|
|
7
7
|
"specsDir": null,
|
|
8
8
|
"dashboard": { "autostart": true },
|
|
9
9
|
"design": "console",
|
|
10
|
+
"theme": "dark",
|
|
11
|
+
"boardView": "list",
|
|
12
|
+
"sideHidden": false,
|
|
13
|
+
"expandedPhases": [],
|
|
14
|
+
"activeTab": "board",
|
|
15
|
+
"chatOpen": false,
|
|
16
|
+
"kanbanColumns": ["todo", "in_progress", "to_validate", "to_analyze", "done", "blocked"],
|
|
17
|
+
"kanbanPageSize": 10,
|
|
18
|
+
"navTabs": [
|
|
19
|
+
{"id":"board","enabled":true},
|
|
20
|
+
{"id":"chat","enabled":true},
|
|
21
|
+
{"id":"requests","enabled":true},
|
|
22
|
+
{"id":"attention","enabled":true},
|
|
23
|
+
{"id":"backlog","enabled":true},
|
|
24
|
+
{"id":"workflow","enabled":true},
|
|
25
|
+
{"id":"team","enabled":true},
|
|
26
|
+
{"id":"files","enabled":true},
|
|
27
|
+
{"id":"notes","enabled":false},
|
|
28
|
+
{"id":"meeting","enabled":false},
|
|
29
|
+
{"id":"info","enabled":true},
|
|
30
|
+
{"id":"docs","enabled":true},
|
|
31
|
+
{"id":"personalize","enabled":true}
|
|
32
|
+
],
|
|
10
33
|
"runners": {
|
|
11
34
|
"claude": "claude -p --permission-mode acceptEdits",
|
|
12
35
|
"codex": "codex exec"
|
|
File without changes
|
|
@@ -3,7 +3,7 @@ name: generate-dashboard
|
|
|
3
3
|
description: Turn a description (or an auto-analysis) into a new custom dashboard page, as a declarative block spec that automatically matches every design the dashboard ships.
|
|
4
4
|
capability: customization
|
|
5
5
|
inputs: A description of what the dashboard should show (from the Customize page or chat), or a chosen candidate from propose-customizations; the project's specs/plans/code as source material.
|
|
6
|
-
outputs: A validated block-spec JSON file at .spectoflow/
|
|
6
|
+
outputs: A validated block-spec JSON file at .spectoflow/dashboards/<id>.json, live in the dashboard's nav on the next tick.
|
|
7
7
|
standard: declarative UI generation; Few's dashboard design principles
|
|
8
8
|
---
|
|
9
9
|
# Generate dashboard
|
|
@@ -67,8 +67,8 @@ computed stats the Board already uses (see step 5).
|
|
|
67
67
|
|
|
68
68
|
### 4. Choose blocks — the vocabulary
|
|
69
69
|
|
|
70
|
-
Pick from exactly these block types (anything else is invisible to the renderer —
|
|
71
|
-
|
|
70
|
+
Pick from exactly these block types (anything else is invisible to the renderer — the block schema
|
|
71
|
+
documented below is enforced by `spectoflow dashboard validate`):
|
|
72
72
|
|
|
73
73
|
| `type` | Shape | Use for |
|
|
74
74
|
|---|---|---|
|
|
@@ -102,7 +102,7 @@ in one screen — 4-8 blocks is a healthy page, not 20.
|
|
|
102
102
|
### 6. Pick an id, a title, an icon
|
|
103
103
|
|
|
104
104
|
- `id`: lowercase kebab-case, unique among existing custom dashboards (list
|
|
105
|
-
`.spectoflow/
|
|
105
|
+
`.spectoflow/dashboards/*.json` first) — this becomes the URL segment and the file name.
|
|
106
106
|
- `title`: short, a few words, shown as the nav tab label.
|
|
107
107
|
- `icon`: one of `board`, `requests`, `backlog`, `workflow`, `agents`, `chat`, `info`, `attention`,
|
|
108
108
|
`settings` (the same set the rest of the dashboard uses — pick the closest match; default to `info`
|
|
@@ -110,19 +110,21 @@ in one screen — 4-8 blocks is a healthy page, not 20.
|
|
|
110
110
|
|
|
111
111
|
### 7. Write and verify
|
|
112
112
|
|
|
113
|
-
Write the spec to `.spectoflow/
|
|
113
|
+
Write the spec to `.spectoflow/dashboards/<id>.json` (pretty-printed, 2-space indent). Then
|
|
114
114
|
**verify it, don't assume it's valid** — run:
|
|
115
115
|
```
|
|
116
|
-
|
|
116
|
+
spectoflow dashboard validate .spectoflow/dashboards/<id>.json
|
|
117
117
|
```
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
(use `npx spectoflow …` if spectoflow isn't on PATH)
|
|
119
|
+
|
|
120
|
+
If the output shows errors, fix them and re-run before reporting done — a spec the dashboard's own
|
|
121
|
+
validator rejects is never a finished deliverable, it would simply be skipped and the user would see
|
|
122
|
+
nothing.
|
|
121
123
|
|
|
122
124
|
## Output contract
|
|
123
125
|
|
|
124
|
-
- One file: `.spectoflow/
|
|
125
|
-
|
|
126
|
+
- One file: `.spectoflow/dashboards/<id>.json`, valid against the block schema
|
|
127
|
+
(verified per step 7 with `spectoflow dashboard validate`, not assumed).
|
|
126
128
|
- Progress and completion reported to the orchestrator and group chat:
|
|
127
129
|
|
|
128
130
|
```
|
|
@@ -147,6 +149,6 @@ user would see nothing.
|
|
|
147
149
|
- Stephen Few, *Information Dashboard Design* (O'Reilly/Analytics Press) — one purpose per dashboard,
|
|
148
150
|
the plainest chart that carries the point, single-screen legibility.
|
|
149
151
|
https://www.perceptualedge.com/library.php
|
|
150
|
-
-
|
|
151
|
-
|
|
152
|
+
- `spectoflow dashboard validate <file>` — the declarative block vocabulary's validator (in the
|
|
153
|
+
spectoflow package; enforces the block schema and bind allow-list).
|
|
152
154
|
- `dashboard/public/stats.js` — the exact shape of the live stats object bindable via `bind`.
|
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
/*
|
|
3
|
-
* spectoflow dashboard — per-project route logic, vendored into every project's
|
|
4
|
-
* .spectoflow/dashboard/ (copied by init/update, exactly like server.js). Split out of server.js so
|
|
5
|
-
* a single global hub process (lib/hub-server.js) can load a different project's routes on demand —
|
|
6
|
-
* see docs/multi-project-hub-design.md's "the server must split in two" addendum.
|
|
7
|
-
*
|
|
8
|
-
* createHandlers(root) returns the per-project surface a listener-owning process needs:
|
|
9
|
-
* - handleApi(req, res, u, emit): Promise<boolean> — true if this request was an API route and was
|
|
10
|
-
* handled (caller should not also try static/SPA fallback); false otherwise. Deliberately excludes
|
|
11
|
-
* /api/events: SSE client registration stays owned by whichever file owns the HTTP listener.
|
|
12
|
-
* - watchDirs: string[] — dirs (relative to root) whose changes should emit {type:'change'}. The
|
|
13
|
-
* caller owns the actual fs.watch calls (it owns emit).
|
|
14
|
-
* - onBoot(): call once, the first time this project is opened in a server's lifetime (creates the
|
|
15
|
-
* custom-dashboards dir if missing, reconciles any stale in-flight orchestration).
|
|
16
|
-
*/
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
const path = require('path');
|
|
19
|
-
const store = require('../lib/store');
|
|
20
|
-
const { startRun } = require('./runner');
|
|
21
|
-
const { runSummarize } = require('./summarize');
|
|
22
|
-
const orchestrator = require('./orchestrator');
|
|
23
|
-
const agentsRegistry = require('../lib/agents-registry');
|
|
24
|
-
const files = require('./files');
|
|
25
|
-
|
|
26
|
-
function sendJSON(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); }
|
|
27
|
-
function body(req) { return new Promise((r) => { let b = ''; req.on('data', (c) => b += c); req.on('end', () => { try { r(JSON.parse(b || '{}')); } catch { r({}); } }); }); }
|
|
28
|
-
|
|
29
|
-
function createHandlers(root) {
|
|
30
|
-
// Installed framework version: the manifest records it at init/update time. Fallback to the kit's
|
|
31
|
-
// own package.json — only reachable (and only used) when the server is run straight from templates/
|
|
32
|
-
// (dev/preview), never from an installed project whose sibling package.json belongs to the user.
|
|
33
|
-
function frameworkVersion() {
|
|
34
|
-
try { return JSON.parse(fs.readFileSync(path.join(root, '.spectoflow', '.manifest.json'), 'utf8')).version; } catch {}
|
|
35
|
-
try { const pk = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); if (pk.name === 'spectoflow') return pk.version; } catch {}
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
function project() {
|
|
39
|
-
const p = store.readProject(root);
|
|
40
|
-
const v = frameworkVersion(); if (v) p.version = v;
|
|
41
|
-
p.projectName = path.basename(root);
|
|
42
|
-
p.knownAgents = agentsRegistry.KNOWN_AGENTS.map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
|
|
43
|
-
p.installedAgents = agentsRegistry.installedAgents(root);
|
|
44
|
-
return p;
|
|
45
|
-
}
|
|
46
|
-
function findPlanFileForTask(id) { for (const pl of store.readPlans(root)) for (const ph of pl.phases) if (ph.tasks.find((t) => t.id === id)) return pl.file; return null; }
|
|
47
|
-
|
|
48
|
-
const configPath = () => path.join(root, '.spectoflow', 'config.json');
|
|
49
|
-
function writeConfig(patch) {
|
|
50
|
-
const cp = configPath(); const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
|
|
51
|
-
if (patch.mode && ['autopilot', 'semi', 'manual'].includes(patch.mode)) cfg.mode = patch.mode;
|
|
52
|
-
if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
|
|
53
|
-
if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
|
|
54
|
-
if (typeof patch.agent === 'string' && patch.agent.trim()) {
|
|
55
|
-
const id = patch.agent.trim();
|
|
56
|
-
// Never activate an agent whose CLI isn't actually there — a picked-but-absent agent would just
|
|
57
|
-
// fail silently the next time something tries to run it.
|
|
58
|
-
if (!agentsRegistry.isAgentInstalled(id, root)) {
|
|
59
|
-
const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
|
|
60
|
-
const label = known ? known.label : id;
|
|
61
|
-
throw new Error(`${label} isn't installed here (its command wasn't found on PATH). Install it, then try again.`);
|
|
62
|
-
}
|
|
63
|
-
cfg.agent = id;
|
|
64
|
-
const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
|
|
65
|
-
if (known && known.runner) { cfg.runners = cfg.runners || {}; if (!cfg.runners[id]) cfg.runners[id] = known.runner; }
|
|
66
|
-
}
|
|
67
|
-
fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
|
|
68
|
-
return cfg;
|
|
69
|
-
}
|
|
70
|
-
function promoteAttention(item) {
|
|
71
|
-
return store.addTask(root, { phase: 'Attention', title: item.text, owner: 'user' });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function handleApi(req, res, u, emit) {
|
|
75
|
-
const p = u.pathname;
|
|
76
|
-
|
|
77
|
-
if (p === '/api/project') { sendJSON(res, 200, project()); return true; }
|
|
78
|
-
|
|
79
|
-
if (p === '/api/agentfile' && req.method === 'GET') {
|
|
80
|
-
const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
|
|
81
|
-
const base = path.join(root, '.spectoflow');
|
|
82
|
-
const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
|
|
83
|
-
const abs = path.resolve(base, rel);
|
|
84
|
-
const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
|
|
85
|
-
if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory())
|
|
86
|
-
{ sendJSON(res, 400, { error: 'not an agent/skill file' }); return true; }
|
|
87
|
-
// Symlink guard: the resolved real path must stay within the (real) scope dirs.
|
|
88
|
-
let real; try { real = fs.realpathSync(abs); } catch { real = null; }
|
|
89
|
-
const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
|
|
90
|
-
const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
|
|
91
|
-
const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
|
|
92
|
-
if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory())
|
|
93
|
-
{ sendJSON(res, 400, { error: 'not an agent/skill file' }); return true; }
|
|
94
|
-
sendJSON(res, 200, { content: fs.readFileSync(real, 'utf8') }); return true;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (p === '/api/files/tree' && req.method === 'GET') { sendJSON(res, 200, { tree: files.tree(root) }); return true; }
|
|
98
|
-
if (p === '/api/files/read' && req.method === 'GET') {
|
|
99
|
-
const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
|
|
100
|
-
const r = files.readFile(root, rel);
|
|
101
|
-
sendJSON(res, r.error ? 400 : 200, r); return true;
|
|
102
|
-
}
|
|
103
|
-
if (p === '/api/files/write' && req.method === 'POST') {
|
|
104
|
-
const { path: rel, content } = await body(req);
|
|
105
|
-
const r = files.writeFile(root, rel, content);
|
|
106
|
-
if (r.error) { sendJSON(res, 400, r); return true; }
|
|
107
|
-
emit({ type: 'change' }); sendJSON(res, 200, r); return true;
|
|
108
|
-
}
|
|
109
|
-
if (p === '/api/files/mkdir' && req.method === 'POST') {
|
|
110
|
-
const { path: rel } = await body(req);
|
|
111
|
-
const r = files.mkdir(root, rel);
|
|
112
|
-
if (r.error) { sendJSON(res, 400, r); return true; }
|
|
113
|
-
emit({ type: 'change' }); sendJSON(res, 200, r); return true;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (p === '/api/task' && req.method === 'POST') {
|
|
117
|
-
const { title, phase, file, owner, level } = await body(req);
|
|
118
|
-
if (!title || !String(title).trim()) { sendJSON(res, 400, { error: 'A title is required.' }); return true; }
|
|
119
|
-
const t = store.addTask(root, { title: String(title).trim(), phase, file, owner, level });
|
|
120
|
-
emit({ type: 'change' }); sendJSON(res, 200, { task: t }); return true;
|
|
121
|
-
}
|
|
122
|
-
if (p.startsWith('/api/task/') && req.method === 'PATCH') {
|
|
123
|
-
const id = decodeURIComponent(p.split('/')[3] || ''); const patch = await body(req);
|
|
124
|
-
const file = findPlanFileForTask(id); if (!file) { sendJSON(res, 404, { error: `Task ${id} not found.` }); return true; }
|
|
125
|
-
store.updateTaskLine(root, file, id, patch); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
126
|
-
}
|
|
127
|
-
if (/^\/api\/task\/[^/]+\/comment$/.test(p) && req.method === 'POST') {
|
|
128
|
-
const id = decodeURIComponent(p.split('/')[3] || ''); const { text, action } = await body(req);
|
|
129
|
-
if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty comment.' }); return true; }
|
|
130
|
-
const file = findPlanFileForTask(id); if (!file) { sendJSON(res, 404, { error: `Task ${id} not found.` }); return true; }
|
|
131
|
-
store.addTaskComment(root, file, id, String(text).trim(), 'me');
|
|
132
|
-
if (action === 'analyze') store.updateTaskLine(root, file, id, { status: 'to_analyze' });
|
|
133
|
-
emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
134
|
-
}
|
|
135
|
-
if (p === '/api/workflow/toggle' && req.method === 'POST') {
|
|
136
|
-
const { name } = await body(req); const wf = path.join(root, '.spectoflow', 'workflow.md');
|
|
137
|
-
const lines = fs.readFileSync(wf, 'utf8').split('\n');
|
|
138
|
-
// Must strip a trailing {cap:... skill:... policy} annotation (added in D29) BEFORE stripping
|
|
139
|
-
// "(optional)" — same order as store.js's readWorkflow(), which is what the client's own step
|
|
140
|
-
// names (and so `name` here) are actually derived from. Every step in the default workflow.md
|
|
141
|
-
// template carries one of these annotations, so getting this order wrong breaks toggling
|
|
142
|
-
// every single step, not just an edge case.
|
|
143
|
-
const stepName = (rest) => {
|
|
144
|
-
const ann = rest.match(/\{([^}]*)\}\s*$/);
|
|
145
|
-
if (ann) rest = rest.slice(0, ann.index).trim();
|
|
146
|
-
return rest.replace(/\s*\(optional\)\s*$/i, '').trim();
|
|
147
|
-
};
|
|
148
|
-
for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
|
|
149
|
-
if (m && stepName(m[4]) === name) lines[i] = m[1] + (m[2].trim() ? ' ' : 'x') + m[3] + m[4]; }
|
|
150
|
-
fs.writeFileSync(wf, lines.join('\n')); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (p === '/api/run' && req.method === 'POST') {
|
|
154
|
-
const { prompt, agent } = await body(req);
|
|
155
|
-
if (!prompt || !String(prompt).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
|
|
156
|
-
const r = startRun(root, { prompt, agent }, emit);
|
|
157
|
-
if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
|
|
158
|
-
sendJSON(res, 200, { runId: r.runId }); return true;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (p === '/api/chat/summarize' && req.method === 'POST') {
|
|
162
|
-
const { agent } = await body(req);
|
|
163
|
-
const r = runSummarize(root, { agent }, emit);
|
|
164
|
-
if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
|
|
165
|
-
sendJSON(res, 200, { ok: true }); return true;
|
|
166
|
-
}
|
|
167
|
-
if (p === '/api/chat/clear' && req.method === 'POST') {
|
|
168
|
-
const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
|
|
169
|
-
emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (p === '/api/orchestrate' && req.method === 'POST') {
|
|
173
|
-
const { request } = await body(req);
|
|
174
|
-
if (!request || !String(request).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
|
|
175
|
-
const active = store.readRuntime(root).orchestration;
|
|
176
|
-
if (active && ['running', 'awaiting_approval'].includes(active.status))
|
|
177
|
-
{ sendJSON(res, 409, { error: 'An orchestration is already active.' }); return true; }
|
|
178
|
-
const mode = store.readConfig(root).mode || 'semi';
|
|
179
|
-
orchestrator.runOrchestration({ root, request: String(request).trim(), mode,
|
|
180
|
-
runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, emit)
|
|
181
|
-
.catch((e) => emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
|
|
182
|
-
const o = store.readRuntime(root).orchestration;
|
|
183
|
-
sendJSON(res, 200, { orchestrationId: o && o.id }); return true;
|
|
184
|
-
}
|
|
185
|
-
if (p === '/api/orchestrate/approve' && req.method === 'POST') {
|
|
186
|
-
const { decision, note } = await body(req);
|
|
187
|
-
const ok = orchestrator.submitDecision(decision, note);
|
|
188
|
-
sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' }); return true;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (p === '/api/settings' && req.method === 'POST') {
|
|
192
|
-
const patch = await body(req);
|
|
193
|
-
try { const cfg = writeConfig(patch); emit({ type: 'change' }); sendJSON(res, 200, { config: cfg }); }
|
|
194
|
-
catch (e) { sendJSON(res, 400, { error: String(e && e.message || e) }); }
|
|
195
|
-
return true;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (p === '/api/attention' && req.method === 'POST') {
|
|
199
|
-
const { text } = await body(req);
|
|
200
|
-
if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty note.' }); return true; }
|
|
201
|
-
const rt = store.readRuntime(root); rt.attention = rt.attention || [];
|
|
202
|
-
const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: String(text).trim() };
|
|
203
|
-
rt.attention.unshift(item); store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
204
|
-
sendJSON(res, 200, { item }); return true;
|
|
205
|
-
}
|
|
206
|
-
if (/^\/api\/attention\/[^/]+\/promote$/.test(p) && req.method === 'POST') {
|
|
207
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
208
|
-
const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
|
|
209
|
-
if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
|
|
210
|
-
const t = promoteAttention(it); it.status = 'resolved'; it.promotedTo = t.id;
|
|
211
|
-
store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
212
|
-
sendJSON(res, 200, { task: t }); return true;
|
|
213
|
-
}
|
|
214
|
-
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'PATCH') {
|
|
215
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
216
|
-
const patch = await body(req);
|
|
217
|
-
const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
|
|
218
|
-
if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
|
|
219
|
-
if (typeof patch.text === 'string' && patch.text.trim()) it.text = patch.text.trim();
|
|
220
|
-
if (patch.status && ['open', 'resolved'].includes(patch.status)) it.status = patch.status;
|
|
221
|
-
store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
222
|
-
sendJSON(res, 200, { item: it }); return true;
|
|
223
|
-
}
|
|
224
|
-
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'DELETE') {
|
|
225
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
226
|
-
const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id);
|
|
227
|
-
store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
228
|
-
sendJSON(res, 200, { ok: true }); return true;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function onBoot() {
|
|
235
|
-
// A project that hasn't used Customize yet won't have this dir on disk, and `spectoflow init` on
|
|
236
|
-
// an older install won't have created it either.
|
|
237
|
-
try { fs.mkdirSync(path.join(root, '.spectoflow', 'dashboard', 'custom'), { recursive: true }); } catch (_) {}
|
|
238
|
-
// A process restart loses any in-flight orchestration; without this, a stale 'running' or
|
|
239
|
-
// 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real resume —
|
|
240
|
-
// just clears the wedge so a fresh orchestration can start.
|
|
241
|
-
try { orchestrator.reconcileOnBoot(root); } catch (_) {}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
handleApi,
|
|
246
|
-
watchDirs: ['plans', 'specs', '.spectoflow', '.spectoflow/dashboard/custom'],
|
|
247
|
-
onBoot,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
module.exports = { createHandlers };
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
/*
|
|
3
|
-
* spectoflow dashboard — ZERO-DEPENDENCY server, real-time (SSE + fs.watch), single project.
|
|
4
|
-
* The actual /api/* route behavior lives in ./handlers.js — split out so the future multi-project hub
|
|
5
|
-
* (lib/hub-server.js) can load a different project's handlers.js on demand (see
|
|
6
|
-
* docs/multi-project-hub-design.md's "the server must split in two" addendum). This file remains the
|
|
7
|
-
* direct single-project entry point (`node .spectoflow/dashboard/server.js`, today's `spectoflow
|
|
8
|
-
* dashboard`) — its own external behavior is unchanged by the split.
|
|
9
|
-
*/
|
|
10
|
-
const http = require('http');
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const { createHandlers } = require('./handlers');
|
|
14
|
-
|
|
15
|
-
const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
|
|
16
|
-
const PUBLIC = path.join(__dirname, 'public');
|
|
17
|
-
const ROOT = process.env.SPECTOFLOW_ROOT || path.resolve(__dirname, '..', '..');
|
|
18
|
-
const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon', '.woff2':'font/woff2', '.woff':'font/woff' };
|
|
19
|
-
const clients = new Set();
|
|
20
|
-
function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
|
|
21
|
-
function emit(obj){ const line='data: '+JSON.stringify(obj)+'\n\n'; for(const res of clients) res.write(line); }
|
|
22
|
-
|
|
23
|
-
const handlers = createHandlers(ROOT);
|
|
24
|
-
|
|
25
|
-
function watch(dir){ try{ fs.watch(dir,{recursive:false},()=>emit({type:'change'})); }catch(_){} }
|
|
26
|
-
handlers.onBoot();
|
|
27
|
-
handlers.watchDirs.forEach((d)=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
|
|
28
|
-
|
|
29
|
-
const server = http.createServer(async (req,res)=>{
|
|
30
|
-
const u=new URL(req.url,`http://localhost:${PORT}`); const p=u.pathname;
|
|
31
|
-
try{
|
|
32
|
-
if(p==='/api/events'){
|
|
33
|
-
res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
|
|
34
|
-
res.write('data: '+JSON.stringify({type:'hello'})+'\n\n');
|
|
35
|
-
clients.add(res); req.on('close',()=>clients.delete(res)); return;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (p.startsWith('/api/')) {
|
|
39
|
-
const handled = await handlers.handleApi(req, res, u, emit);
|
|
40
|
-
if (handled) return;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ---- static files, with SPA fallback: a route like /backlog (no file extension)
|
|
44
|
-
// that isn't a real asset serves index.html so client-side routing can take over ----
|
|
45
|
-
let file=p==='/'?'/index.html':p;
|
|
46
|
-
const full=path.join(PUBLIC,path.normalize(file).replace(/^(\.\.[/\\])+/,''));
|
|
47
|
-
if(!full.startsWith(PUBLIC)){ res.writeHead(403); return res.end('Forbidden'); }
|
|
48
|
-
// Local tool: always serve the freshest asset — never let the browser cache a stale app.js/css.
|
|
49
|
-
const noCache = { 'Cache-Control': 'no-store, must-revalidate' };
|
|
50
|
-
fs.readFile(full,(err,data)=>{
|
|
51
|
-
if(err){
|
|
52
|
-
if(req.method==='GET' && !path.extname(p) && !p.startsWith('/api/')){
|
|
53
|
-
return fs.readFile(path.join(PUBLIC,'index.html'),(e2,d2)=>{
|
|
54
|
-
if(e2){ res.writeHead(404); return res.end('Not found'); }
|
|
55
|
-
res.writeHead(200,Object.assign({'Content-Type':MIME['.html']},noCache)); res.end(d2);
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
res.writeHead(404); return res.end('Not found');
|
|
59
|
-
}
|
|
60
|
-
const ext=path.extname(full);
|
|
61
|
-
// fonts are content-hashed by name and safe to cache long-term; everything else is no-store
|
|
62
|
-
const headers = ext==='.woff2'||ext==='.woff' ? { 'Cache-Control':'public, max-age=604800' } : noCache;
|
|
63
|
-
res.writeHead(200,Object.assign({'Content-Type':MIME[ext]||'application/octet-stream'},headers)); res.end(data);
|
|
64
|
-
});
|
|
65
|
-
}catch(e){ sendJSON(res,500,{error:String(e&&e.message||e)}); }
|
|
66
|
-
});
|
|
67
|
-
// pidfile so `spectoflow dashboard stop` can find and stop this server; cleared on exit.
|
|
68
|
-
const LOCK = path.join(ROOT, '.spectoflow', '.dashboard.lock');
|
|
69
|
-
function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, startedAt:new Date().toISOString() })+'\n'); }catch{} }
|
|
70
|
-
function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
|
|
71
|
-
process.on('exit', clearLock);
|
|
72
|
-
['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ clearLock(); process.exit(0); }));
|
|
73
|
-
server.listen(PORT,()=>{ writeLock(); console.log(`spectoflow · dashboard → http://localhost:${PORT}`); console.log(`project root: ${ROOT}`); });
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
/*
|
|
3
|
-
* The dashboard's own view of "which coding agents exist and is one actually installed" — a small,
|
|
4
|
-
* self-contained subset of this package's lib/adapters.js (the richer install-time registry with
|
|
5
|
-
* memory-file content). Duplicated rather than shared: .spectoflow/ must be self-contained (ships
|
|
6
|
-
* into every project), while lib/adapters.js does not ship there. test/agents-registry.test.js
|
|
7
|
-
* guards the two id/bin/runner sets from drifting apart.
|
|
8
|
-
*/
|
|
9
|
-
const fs = require('fs');
|
|
10
|
-
const path = require('path');
|
|
11
|
-
|
|
12
|
-
// headless:false = detectable and selectable as the active agent, but spectoflow never spawns it
|
|
13
|
-
// itself (no confirmed non-interactive one-shot mode) — Run/Orchestrate/Summarize stay disabled for
|
|
14
|
-
// it client-side; runner/summarize.js also refuse server-side (defense in depth). `docsUrl` is that
|
|
15
|
-
// agent's own official CLI docs, surfaced verbatim in the dashboard's Documentation tab. See the
|
|
16
|
-
// longer rationale above lib/adapters.js's REGISTRY, the richer install-time twin of this list —
|
|
17
|
-
// including why DeepSeek Harness isn't here at all, and why some runner strings order their flags
|
|
18
|
-
// the way they do (the trailing prompt must land right after whichever flag takes a value).
|
|
19
|
-
const KNOWN_AGENTS = [
|
|
20
|
-
{ id: 'claude', label: 'Claude Code', bin: 'claude', dirs: ['.claude'], runner: 'claude -p --permission-mode acceptEdits', headless: true, docsUrl: 'https://code.claude.com/docs/en/cli-reference' },
|
|
21
|
-
{ id: 'codex', label: 'Codex', bin: 'codex', dirs: ['.codex'], runner: 'codex exec', headless: true, docsUrl: 'https://developers.openai.com/codex/cli/reference' },
|
|
22
|
-
{ id: 'cursor', label: 'Cursor', bin: 'cursor-agent', dirs: ['.cursor'], runner: 'cursor-agent -p', headless: true, docsUrl: 'https://cursor.com/docs/cli/overview' },
|
|
23
|
-
{ id: 'gemini', label: 'Gemini CLI', bin: 'gemini', dirs: ['.gemini'], runner: 'gemini -p', headless: true, docsUrl: 'https://github.com/google-gemini/gemini-cli' },
|
|
24
|
-
{ id: 'opencode', label: 'OpenCode', bin: 'opencode', dirs: ['.opencode'], runner: 'opencode run --quiet', headless: true, docsUrl: 'https://opencode.ai/docs/cli/' },
|
|
25
|
-
{ id: 'kiro', label: 'Kiro CLI', bin: 'kiro-cli', dirs: ['.kiro'], runner: 'kiro-cli chat --no-interactive --trust-all-tools', headless: true, docsUrl: 'https://kiro.dev/docs/cli/headless/' },
|
|
26
|
-
{ id: 'antigravity', label: 'Antigravity', bin: 'agy', dirs: [], runner: 'agy -p', headless: true, docsUrl: 'https://antigravity.google/docs/cli/headless/' },
|
|
27
|
-
{ id: 'kimi', label: 'Kimi CLI', bin: 'kimi', dirs: [], runner: null, headless: false, docsUrl: 'https://github.com/MoonshotAI/kimi-cli' },
|
|
28
|
-
{ id: 'copilot', label: 'GitHub Copilot CLI', bin: 'copilot', dirs: [], runner: 'copilot -s --allow-all-tools -p', headless: true, docsUrl: 'https://docs.github.com/copilot/concepts/agents/about-copilot-cli' },
|
|
29
|
-
{ id: 'amazon-q', label: 'Amazon Q Developer CLI', bin: 'q', dirs: ['.amazonq'], runner: 'q chat --no-interactive --trust-all-tools', headless: true, docsUrl: 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-chat.html' },
|
|
30
|
-
{ id: 'droid', label: 'Factory Droid CLI', bin: 'droid', dirs: ['.factory'], runner: 'droid exec', headless: true, docsUrl: 'https://docs.factory.ai/droid-exec/overview' },
|
|
31
|
-
{ id: 'auggie', label: 'Auggie CLI', bin: 'auggie', dirs: ['.augment'], runner: 'auggie --quiet --print', headless: true, docsUrl: 'https://docs.augmentcode.com/cli/overview' },
|
|
32
|
-
{ id: 'goose', label: 'Goose CLI', bin: 'goose', dirs: ['.goose'], runner: 'goose run -t', headless: true, docsUrl: 'https://block.github.io/goose/' },
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
// Is `bin` an executable resolvable on PATH? On win32, an extension from PATHEXT is required, so we
|
|
36
|
-
// try each; we also try the bare name (covers test fixtures and extensionless shims).
|
|
37
|
-
function binOnPath(bin, { env = process.env, platform = process.platform } = {}) {
|
|
38
|
-
const raw = env.PATH || env.Path || '';
|
|
39
|
-
const dirs = raw.split(path.delimiter).filter(Boolean);
|
|
40
|
-
const exts =
|
|
41
|
-
platform === 'win32' ? ['', ...(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)] : [''];
|
|
42
|
-
for (const d of dirs) {
|
|
43
|
-
for (const e of exts) {
|
|
44
|
-
if (fs.existsSync(path.join(d, bin + e))) return true;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// True if `id` looks genuinely installed: its bin resolves on PATH, or the project already has its
|
|
51
|
-
// config dir (a project can be set up for an agent whose bin isn't on THIS machine's PATH, e.g. a
|
|
52
|
-
// remote/CI runner). Unknown ids are never "installed".
|
|
53
|
-
function isAgentInstalled(id, projectRoot, opts) {
|
|
54
|
-
const a = KNOWN_AGENTS.find((x) => x.id === id);
|
|
55
|
-
if (!a) return false;
|
|
56
|
-
if (a.bin && binOnPath(a.bin, opts)) return true;
|
|
57
|
-
return (a.dirs || []).some((d) => fs.existsSync(path.join(projectRoot, d)));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// ids of every known agent actually installed for this project, in KNOWN_AGENTS (priority) order.
|
|
61
|
-
function installedAgents(projectRoot, opts) {
|
|
62
|
-
return KNOWN_AGENTS.filter((a) => isAgentInstalled(a.id, projectRoot, opts)).map((a) => a.id);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
module.exports = { KNOWN_AGENTS, binOnPath, isAgentInstalled, installedAgents };
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|