@webship.co/workspace 0.0.0-stage → 1.0.0-beta1
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/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +466 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1439 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +7923 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +553 -0
package/ai-items.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI-items domain: agents, skills and prompts, in their three places —
|
|
3
|
+
* this workspace's folders (drafts), ~/.claude (what a session loads) and the
|
|
4
|
+
* shared repo (what the team has) — plus the git-blob comparison that says
|
|
5
|
+
* when the repo's copy differs from the folder's. The handlers live in
|
|
6
|
+
* actions/ai-items.js; the rendering in server.js imports the same primitives,
|
|
7
|
+
* so the buttons and the CLI can never disagree about where things are.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const { loadWorkspaces, workspaceDir, sourceOf } = require('./workspaces');
|
|
14
|
+
const { repoTree, invalidateRepoTree, gitBlobSha } = require('./repo-blobs');
|
|
15
|
+
|
|
16
|
+
/* ---------------- file-item workspaces (agents, skills, prompts, docs) --- */
|
|
17
|
+
|
|
18
|
+
// Where each editable kind installs into the live Claude Code CLI setup.
|
|
19
|
+
const INSTALL_TARGETS = {
|
|
20
|
+
'ai-agents': path.join(process.env.HOME, '.claude', 'agents'),
|
|
21
|
+
'ai-skills': path.join(process.env.HOME, '.claude', 'skills'),
|
|
22
|
+
// Commands and prompts are two kinds that share one destination: a command is a
|
|
23
|
+
// $ARGUMENTS spec typed mid-session, a prompt is a whole `claude …` launch command
|
|
24
|
+
// for a new session. Installing one writes over the other where names collide.
|
|
25
|
+
'ai-commands': path.join(process.env.HOME, '.claude', 'commands'),
|
|
26
|
+
'ai-prompts': path.join(process.env.HOME, '.claude', 'commands'),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Agents, skills and prompts exist in three places: this workspace folder
|
|
30
|
+
// (drafts), ~/.claude (what a session loads) and webship/ai-agents (what
|
|
31
|
+
// the team has). The item lists show all three, merged by name, so it is
|
|
32
|
+
// obvious what is only local, only installed, or only in the repo.
|
|
33
|
+
const AI_ITEMS_REPO = () => process.env.AI_ITEMS_REPO || sourceOf('ai_items', 'repo', 'webship/ai-agents');
|
|
34
|
+
const AI_ITEMS_REF = () => process.env.AI_ITEMS_REF || sourceOf('ai_items', 'ref', 'main');
|
|
35
|
+
// A prompt is the whole `claude …` launch command for a NEW session, so the only way
|
|
36
|
+
// to use one is to start a session with it — the dashboard's New screen form, or the
|
|
37
|
+
// CLI by hand. It is not a slash command, so nothing of a prompt's syncs into ~/.claude
|
|
38
|
+
// or a project's .claude/. It keeps its INSTALL_TARGETS entry only so listClaudeItems
|
|
39
|
+
// can still SHOW what a machine already has there.
|
|
40
|
+
// AI Commands is the kind that DOES install: same destination, different shape.
|
|
41
|
+
const NO_CLAUDE_SYNC = new Set(['ai-prompts']);
|
|
42
|
+
|
|
43
|
+
// Where each kind sits IN THE REPO. Prompts are the one kind whose directory name
|
|
44
|
+
// differs from the CLI's: webship/ai-agents keeps them at the top level, in
|
|
45
|
+
// prompts/, beside the agents and skills they invoke.
|
|
46
|
+
const AI_ITEMS_REPO_PATHS = { 'ai-agents': '.claude/agents', 'ai-skills': '.claude/skills',
|
|
47
|
+
'ai-commands': '.claude/commands', 'ai-prompts': 'prompts' };
|
|
48
|
+
// Where each kind installs INSIDE A PROJECT — always the project's own .claude/,
|
|
49
|
+
// which is what Claude Code reads there. Not the same map as above: a project's
|
|
50
|
+
// prompts go to .claude/commands, never to a prompts/ directory of its own.
|
|
51
|
+
const AI_ITEMS_PROJECT_PATHS = { 'ai-agents': '.claude/agents', 'ai-skills': '.claude/skills',
|
|
52
|
+
'ai-commands': '.claude/commands', 'ai-prompts': '.claude/commands' };
|
|
53
|
+
|
|
54
|
+
// What ~/.claude currently has, by kind.
|
|
55
|
+
function listClaudeItems(key) {
|
|
56
|
+
const dir = INSTALL_TARGETS[key];
|
|
57
|
+
if (!dir) return new Set();
|
|
58
|
+
try {
|
|
59
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
60
|
+
if (key === 'ai-skills') {
|
|
61
|
+
return new Set(entries.filter((e) => e.isDirectory()
|
|
62
|
+
&& fs.existsSync(path.join(dir, e.name, 'SKILL.md'))).map((e) => e.name));
|
|
63
|
+
}
|
|
64
|
+
return new Set(entries.filter((e) => e.isFile() && e.name.endsWith('.md')
|
|
65
|
+
&& !['README.md', 'AGENTS.md', 'CLAUDE.md'].includes(e.name))
|
|
66
|
+
.map((e) => e.name.replace(/\.md$/, '')));
|
|
67
|
+
} catch (_) { return new Set(); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Blob shas for every shared item, grouped by name, from the ONE cached recursive
|
|
71
|
+
// Trees call repo-blobs makes per repo@ref — the same identity git itself uses, so
|
|
72
|
+
// comparing them against locally computed blob shas answers "does the repo's copy
|
|
73
|
+
// differ from this folder's?" without fetching a single file body. `null` means
|
|
74
|
+
// the repo was unreachable (an unauthenticated container), which the UI states
|
|
75
|
+
// rather than implying the repo has nothing.
|
|
76
|
+
async function repoItemBlobs(key) {
|
|
77
|
+
if (!AI_ITEMS_REPO_PATHS[key]) return null;
|
|
78
|
+
const tree = await repoTree(AI_ITEMS_REPO(), AI_ITEMS_REF());
|
|
79
|
+
if (!tree) return null;
|
|
80
|
+
const prefix = `${AI_ITEMS_REPO_PATHS[key]}/`;
|
|
81
|
+
const map = new Map(); // item name -> Map<path-inside-item, blob sha>
|
|
82
|
+
for (const [p, sha] of tree) {
|
|
83
|
+
if (!p.startsWith(prefix)) continue;
|
|
84
|
+
const rest = p.slice(prefix.length);
|
|
85
|
+
let name; let inner;
|
|
86
|
+
if (key === 'ai-skills') {
|
|
87
|
+
const slash = rest.indexOf('/');
|
|
88
|
+
if (slash < 1) continue;
|
|
89
|
+
name = rest.slice(0, slash);
|
|
90
|
+
inner = rest.slice(slash + 1);
|
|
91
|
+
} else {
|
|
92
|
+
if (!rest.endsWith('.md') || rest.includes('/')) continue;
|
|
93
|
+
name = rest.slice(0, -3);
|
|
94
|
+
inner = '';
|
|
95
|
+
}
|
|
96
|
+
if (['README', 'AGENTS', 'CLAUDE', 'RULES'].includes(name)) continue;
|
|
97
|
+
if (!map.has(name)) map.set(name, new Map());
|
|
98
|
+
map.get(name).set(inner, sha);
|
|
99
|
+
}
|
|
100
|
+
return map;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The names alone — the same tree, so a page listing names AND comparing blobs
|
|
104
|
+
// still costs one network call, not two.
|
|
105
|
+
async function listRepoItems(key) {
|
|
106
|
+
const blobs = await repoItemBlobs(key);
|
|
107
|
+
return blobs === null ? null : new Set(blobs.keys());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Does the repo's copy of one item differ from this folder's? null = not comparable
|
|
111
|
+
// (not in the repo, or the tree was unreachable).
|
|
112
|
+
function repoDiffers(key, name, repoBlobs) {
|
|
113
|
+
if (!repoBlobs || !repoBlobs.has(name)) return null;
|
|
114
|
+
const theirs = repoBlobs.get(name);
|
|
115
|
+
const dir = workspaceDir(key);
|
|
116
|
+
try {
|
|
117
|
+
if (key !== 'ai-skills') {
|
|
118
|
+
return theirs.get('') !== gitBlobSha(path.join(dir, `${name}.md`));
|
|
119
|
+
}
|
|
120
|
+
// A skill is a directory: same files with the same blob shas, or it differs.
|
|
121
|
+
const base = path.join(dir, name);
|
|
122
|
+
const mine = new Map();
|
|
123
|
+
const walk = (d, rel) => {
|
|
124
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
125
|
+
if (e.name === '.git') continue;
|
|
126
|
+
const full = path.join(d, e.name);
|
|
127
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
128
|
+
if (e.isDirectory()) walk(full, r);
|
|
129
|
+
else if (e.isFile()) mine.set(r, gitBlobSha(full));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk(base, '');
|
|
133
|
+
if (mine.size !== theirs.size) return true;
|
|
134
|
+
for (const [rel, sha] of theirs) if (mine.get(rel) !== sha) return true;
|
|
135
|
+
return false;
|
|
136
|
+
} catch (_) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ITEM_TEMPLATES = {
|
|
142
|
+
agents: (name) => `---
|
|
143
|
+
name: ${name}
|
|
144
|
+
description: Use this agent to <when to invoke it>.
|
|
145
|
+
tools:
|
|
146
|
+
- Bash
|
|
147
|
+
- Read
|
|
148
|
+
- Glob
|
|
149
|
+
- Grep
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
# ${name}
|
|
153
|
+
|
|
154
|
+
You are the specialist agent for <what this agent does>.
|
|
155
|
+
|
|
156
|
+
## Instructions
|
|
157
|
+
|
|
158
|
+
- <how to work>
|
|
159
|
+
`,
|
|
160
|
+
skills: (name) => `---
|
|
161
|
+
name: ${name}
|
|
162
|
+
description: <what this skill does and when to use it>
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
# ${name}
|
|
166
|
+
|
|
167
|
+
## Instructions
|
|
168
|
+
|
|
169
|
+
- <steps the skill follows>
|
|
170
|
+
`,
|
|
171
|
+
prompts: (name) => `<Write the reusable prompt here — installing it makes it available in Claude Code as /${name}>
|
|
172
|
+
`,
|
|
173
|
+
docs: (name) => `# ${name}
|
|
174
|
+
|
|
175
|
+
<Write the document here — Markdown; use "Make PDF" to render a PDF next to it.>
|
|
176
|
+
`,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// The file that holds an item's content, given its display name.
|
|
180
|
+
function itemFile(key, name) {
|
|
181
|
+
const dir = workspaceDir(key);
|
|
182
|
+
if (key === 'ai-skills') return path.join(dir, name, 'SKILL.md');
|
|
183
|
+
return path.join(dir, `${name}.md`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Where an install can land besides the global ~/.claude: any real project, so a session started
|
|
187
|
+
// under it (and only there) sees the item. Site workspaces only — a "project" in `docs/` or
|
|
188
|
+
// `agents/` itself would be another item, not something Claude Code scopes to.
|
|
189
|
+
function installTargetWorkspaces() {
|
|
190
|
+
return Object.values(loadWorkspaces()).filter((w) => w.kind !== 'files');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Every write toward the repo or the folders makes the cached tree stale.
|
|
194
|
+
function invalidateRepoCaches() {
|
|
195
|
+
invalidateRepoTree(AI_ITEMS_REPO());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
INSTALL_TARGETS, NO_CLAUDE_SYNC, AI_ITEMS_REPO, AI_ITEMS_REF, AI_ITEMS_REPO_PATHS, AI_ITEMS_PROJECT_PATHS,
|
|
200
|
+
listClaudeItems, listRepoItems, repoItemBlobs, gitBlobSha, repoDiffers,
|
|
201
|
+
ITEM_TEMPLATES, itemFile, installTargetWorkspaces, invalidateRepoCaches,
|
|
202
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who the assistant's avatar can be, and which voices it can speak in.
|
|
3
|
+
*
|
|
4
|
+
* Avatars. Only the ones named in settings.yml `assistant.avatars:` are offered, so a machine shows
|
|
5
|
+
* what its owner chose and nothing else. `druplicon` ships with the dashboard; any other name is looked
|
|
6
|
+
* up in the avatars workspace (`<root>/avatars/<project>/`): a character file there
|
|
7
|
+
* (`characters/<name>.json` or `characters/osa-<name>.json`) gives its model, display name and credit,
|
|
8
|
+
* and a bare model (`public/models/<name>.vrm`) is enough on its own. The models stay where they are and
|
|
9
|
+
* are served from there, never copied into this repository: their licences are their own, and some
|
|
10
|
+
* (fan-made ones) are for personal use only.
|
|
11
|
+
*
|
|
12
|
+
* Voices. Besides the browser's own voices, the ElevenLabs voices of the account in settings.yml
|
|
13
|
+
* `elevenlabs:`, once a real key is there. Listing them is free; speaking with one spends credits, which
|
|
14
|
+
* is why it happens only when someone picks an ElevenLabs voice. The key never leaves this server: the
|
|
15
|
+
* page posts the words and gets the audio back. What was spoken is kept in `.voice-cache/`, so a
|
|
16
|
+
* sentence heard again costs nothing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MODEL = 'eleven_multilingual_v2';
|
|
24
|
+
const CACHE_DIR = path.join(__dirname, '.voice-cache');
|
|
25
|
+
const CACHE_MAX = 400;
|
|
26
|
+
const MAX_TEXT = 600;
|
|
27
|
+
const BASES = {
|
|
28
|
+
us: 'https://api.us.elevenlabs.io',
|
|
29
|
+
eu: 'https://api.eu.residency.elevenlabs.io',
|
|
30
|
+
in: 'https://api.in.residency.elevenlabs.io',
|
|
31
|
+
sg: 'https://api.sg.residency.elevenlabs.io',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
35
|
+
// The avatars that ship with the dashboard, in public/avatar/.
|
|
36
|
+
const BUILT_IN = {
|
|
37
|
+
druplicon: { name: 'Druplicon', model: '/avatar/druplicon.vrm',
|
|
38
|
+
credit: 'Druplicon, the Drupal drop, built in code. Druplicon is licensed under the GPL; Drupal is a registered trademark of Dries Buytaert.' },
|
|
39
|
+
};
|
|
40
|
+
const titleOf = (id) => id.replace(/^osa-/, '').split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
41
|
+
|
|
42
|
+
/** The set of moves an avatar does beyond the shared ones (public/assistant-avatar-moves.js), or ''. */
|
|
43
|
+
const moveSetOf = (id) => (/^labiba/.test(String(id || '')) ? 'labiba' : '');
|
|
44
|
+
|
|
45
|
+
/** The hair colour an avatar starts with (settings.yml assistant.avatar_hair.<id>), as #rrggbb, or ''. */
|
|
46
|
+
function hairOf(settings, id) {
|
|
47
|
+
const v = String(((settings.assistant || {}).avatar_hair || {})[id] || '').trim().replace(/^['"]|['"]$/g, '');
|
|
48
|
+
return /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Claude Code agent an avatar brings (settings.yml assistant.avatar_agents.<id>), when it is
|
|
53
|
+
* installed for this user; '' keeps the assistant's own agent.
|
|
54
|
+
*/
|
|
55
|
+
function agentOf(settings, id, home = process.env.HOME || '') {
|
|
56
|
+
const name = String(((settings.assistant || {}).avatar_agents || {})[id] || '').trim();
|
|
57
|
+
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(name) && fs.existsSync(path.join(home, '.claude', 'agents', `${name}.md`)) ? name : '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The avatars settings.yml names, with what the page needs to show each one. Unknown names are left out. */
|
|
61
|
+
function avatarCast(settings = {}, root = '') {
|
|
62
|
+
const a = settings.assistant || {};
|
|
63
|
+
const named = Array.isArray(a.avatars) && a.avatars.length ? a.avatars : ['druplicon'];
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const raw of named) {
|
|
66
|
+
const id = String(raw || '').trim().toLowerCase();
|
|
67
|
+
if (!NAME_RE.test(id) || id === 'none' || out.some((x) => x.id === id)) continue;
|
|
68
|
+
const more = { moves: moveSetOf(id), hair: hairOf(settings, id) };
|
|
69
|
+
// The model's link carries its file's time, so a rebuilt model is fetched at once rather than after
|
|
70
|
+
// the hour a browser may keep it.
|
|
71
|
+
if (BUILT_IN[id]) { out.push({ id, ...BUILT_IN[id], model: `${BUILT_IN[id].model}?v=${stamp(path.join(__dirname, 'public', BUILT_IN[id].model))}`, ...more }); continue; }
|
|
72
|
+
const found = findAvatar(id, root);
|
|
73
|
+
if (found) out.push({ id, name: found.name, model: `/avatar/model/${id}.vrm?v=${stamp(found.file)}`, credit: found.credit, ...more });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const stamp = (file) => { try { return Math.round(fs.statSync(file).mtimeMs).toString(36); } catch (_) { return '0'; } };
|
|
79
|
+
|
|
80
|
+
/** The model file of an avatar, inside `<root>/avatars`, or null. */
|
|
81
|
+
function findAvatar(id, root) {
|
|
82
|
+
if (!NAME_RE.test(id) || !root) return null;
|
|
83
|
+
const base = path.join(root, 'avatars');
|
|
84
|
+
let projects = [];
|
|
85
|
+
try { projects = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name).sort(); } catch (_) { return null; }
|
|
86
|
+
const inside = (file) => {
|
|
87
|
+
try {
|
|
88
|
+
const real = fs.realpathSync(file);
|
|
89
|
+
return real.startsWith(fs.realpathSync(base) + path.sep) && /\.vrm$/i.test(real) && fs.statSync(real).isFile() ? real : null;
|
|
90
|
+
} catch (_) { return null; }
|
|
91
|
+
};
|
|
92
|
+
for (const p of projects) {
|
|
93
|
+
for (const stem of [id, `osa-${id}`]) {
|
|
94
|
+
let c = null;
|
|
95
|
+
try { c = JSON.parse(fs.readFileSync(path.join(base, p, 'characters', `${stem}.json`), 'utf8')); } catch (_) { continue; }
|
|
96
|
+
const rel = String((c && c.model) || '').replace(/^\/+/, '');
|
|
97
|
+
if (!/^models\/[\w.-]+\.vrm$/i.test(rel)) continue;
|
|
98
|
+
const file = inside(path.join(base, p, 'public', rel));
|
|
99
|
+
if (file) return { file, name: String(c.name || titleOf(id)).slice(0, 40), credit: String(c.credit || '').slice(0, 400) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const p of projects) {
|
|
103
|
+
for (const stem of [id, `osa-${id}`]) {
|
|
104
|
+
const file = inside(path.join(base, p, 'public', 'models', `${stem}.vrm`));
|
|
105
|
+
if (file) return { file, name: titleOf(id), credit: `From the ${p} avatar project; its README states the model's licence.` };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** GET /avatar/model/<id>.vrm: only a model of an avatar settings.yml names. */
|
|
112
|
+
function serveAvatarModel(res, id, settings, root) {
|
|
113
|
+
const listed = avatarCast(settings, root).some((a) => a.id === id && !BUILT_IN[a.id]);
|
|
114
|
+
const found = listed ? findAvatar(id, root) : null;
|
|
115
|
+
if (!found) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('No such avatar.'); }
|
|
116
|
+
res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' });
|
|
117
|
+
fs.createReadStream(found.file).pipe(res);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- ElevenLabs ----------------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/** The account's key from settings.yml, or '' while it is a placeholder or missing. */
|
|
123
|
+
function elevenKey(settings = {}) {
|
|
124
|
+
const k = String((settings.elevenlabs && settings.elevenlabs.api_key) || '').trim();
|
|
125
|
+
return /^[A-Za-z0-9_.-]{20,}$/.test(k) && !/change/i.test(k) ? k : '';
|
|
126
|
+
}
|
|
127
|
+
const elevenBase = (settings = {}) => process.env.ELEVENLABS_API_BASE
|
|
128
|
+
|| BASES[String((settings.elevenlabs && settings.elevenlabs.region) || '').toLowerCase()] || 'https://api.elevenlabs.io';
|
|
129
|
+
|
|
130
|
+
let voicesMemo = { at: 0, sig: '', list: null };
|
|
131
|
+
|
|
132
|
+
/** The account's voices, `{ id, name, label }`, kept ten minutes. Free: it lists, it does not speak. */
|
|
133
|
+
async function elevenVoices(settings = {}, { fetchImpl = fetch } = {}) {
|
|
134
|
+
const key = elevenKey(settings);
|
|
135
|
+
if (!key) return { connected: false, voices: [] };
|
|
136
|
+
const sig = crypto.createHash('sha256').update(key + elevenBase(settings)).digest('hex');
|
|
137
|
+
if (voicesMemo.sig === sig && voicesMemo.list && Date.now() - voicesMemo.at < 600000) return { connected: true, voices: voicesMemo.list };
|
|
138
|
+
const r = await fetchImpl(`${elevenBase(settings)}/v2/voices?page_size=100`, { headers: { 'xi-api-key': key }, signal: AbortSignal.timeout(10000) });
|
|
139
|
+
if (!r.ok) return { connected: true, voices: [], error: r.status === 401 ? 'ElevenLabs rejected the key.' : `ElevenLabs answered ${r.status}.` };
|
|
140
|
+
const body = await r.json();
|
|
141
|
+
const list = (body.voices || []).filter((v) => /^[A-Za-z0-9]{16,32}$/.test(String(v.voice_id || ''))).map((v) => {
|
|
142
|
+
const l = v.labels || {};
|
|
143
|
+
return { id: v.voice_id, name: String(v.name || v.voice_id).slice(0, 60), label: [l.accent, l.gender, l.age].filter(Boolean).join(', ').slice(0, 60) };
|
|
144
|
+
});
|
|
145
|
+
voicesMemo = { at: Date.now(), sig, list };
|
|
146
|
+
return { connected: true, voices: list };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The audio of `text` in an ElevenLabs voice, as an mp3 Buffer; from the cache when it was spoken before. */
|
|
150
|
+
async function elevenSpeak(settings = {}, { voice, text }, { fetchImpl = fetch, cacheDir = CACHE_DIR } = {}) {
|
|
151
|
+
const key = elevenKey(settings);
|
|
152
|
+
if (!key) throw Object.assign(new Error('ElevenLabs is not connected.'), { status: 409 });
|
|
153
|
+
const id = String(voice || '');
|
|
154
|
+
const words = String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
|
|
155
|
+
if (!/^[A-Za-z0-9]{16,32}$/.test(id) || !words) throw Object.assign(new Error('A voice and some words, please.'), { status: 400 });
|
|
156
|
+
const model = String((settings.elevenlabs && (settings.elevenlabs.model_id || settings.elevenlabs.model)) || DEFAULT_MODEL).replace(/[^\w.-]/g, '') || DEFAULT_MODEL;
|
|
157
|
+
const file = path.join(cacheDir, `${crypto.createHash('sha256').update(`${id}\n${model}\n${words}`).digest('hex')}.mp3`);
|
|
158
|
+
try { return fs.readFileSync(file); } catch (_) { /* not spoken yet */ }
|
|
159
|
+
const r = await fetchImpl(`${elevenBase(settings)}/v1/text-to-speech/${id}?output_format=mp3_44100_128`, {
|
|
160
|
+
method: 'POST', signal: AbortSignal.timeout(30000),
|
|
161
|
+
headers: { 'xi-api-key': key, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
|
|
162
|
+
body: JSON.stringify({ text: words, model_id: model }),
|
|
163
|
+
});
|
|
164
|
+
if (!r.ok) throw Object.assign(new Error(r.status === 401 ? 'ElevenLabs rejected the key.' : `ElevenLabs answered ${r.status}.`), { status: 502 });
|
|
165
|
+
const audio = Buffer.from(await r.arrayBuffer());
|
|
166
|
+
try {
|
|
167
|
+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
168
|
+
fs.writeFileSync(file, audio, { mode: 0o600 });
|
|
169
|
+
const kept = fs.readdirSync(cacheDir).filter((f) => f.endsWith('.mp3'))
|
|
170
|
+
.map((f) => ({ f, t: fs.statSync(path.join(cacheDir, f)).mtimeMs })).sort((a, b) => b.t - a.t);
|
|
171
|
+
for (const old of kept.slice(CACHE_MAX)) fs.unlinkSync(path.join(cacheDir, old.f));
|
|
172
|
+
} catch (_) { /* spoken, just not kept */ }
|
|
173
|
+
return audio;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { BUILT_IN, avatarCast, moveSetOf, hairOf, agentOf, findAvatar, serveAvatarModel, elevenKey, elevenVoices, elevenSpeak };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Suggested prompts for an empty assistant conversation, made from each workspace's own noun and
|
|
2
|
+
// actions. A workspace can replace them with `assistant: suggestions:`; an empty list turns them off.
|
|
3
|
+
|
|
4
|
+
const MAX_SUGGESTIONS = 5;
|
|
5
|
+
const MAX_LENGTH = 120;
|
|
6
|
+
|
|
7
|
+
const an = (noun) => (/^[aeiou]/i.test(noun) ? 'an' : 'a');
|
|
8
|
+
|
|
9
|
+
// null = not given (use the defaults), [] = none.
|
|
10
|
+
function cleanSuggestions(list) {
|
|
11
|
+
if (!Array.isArray(list)) return null;
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const item of list) {
|
|
15
|
+
if (typeof item !== 'string') continue;
|
|
16
|
+
const text = item.replace(/\s+/g, ' ').trim().slice(0, MAX_LENGTH);
|
|
17
|
+
if (!text || seen.has(text.toLowerCase())) continue;
|
|
18
|
+
seen.add(text.toLowerCase());
|
|
19
|
+
out.push(text);
|
|
20
|
+
if (out.length >= MAX_SUGGESTIONS) break;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HOME = [
|
|
26
|
+
'What can this dashboard do?',
|
|
27
|
+
'Which workspaces have something running right now?',
|
|
28
|
+
'Where do I start on a new machine?',
|
|
29
|
+
'How do I build a new project?',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const SETTINGS = [
|
|
33
|
+
'What can I change on this page?',
|
|
34
|
+
'What does each setting here control?',
|
|
35
|
+
'Which of these settings matter most on a new machine?',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const COMMANDS = [
|
|
39
|
+
'What can I run from here?',
|
|
40
|
+
'How do I add a command of my own?',
|
|
41
|
+
'Which commands act on a project, and which on a whole workspace?',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function defaultSuggestions(context, meta) {
|
|
45
|
+
const ctx = String(context || 'home');
|
|
46
|
+
if (ctx === 'settings') return SETTINGS;
|
|
47
|
+
if (ctx === 'commands') return COMMANDS;
|
|
48
|
+
if (!meta) return HOME;
|
|
49
|
+
|
|
50
|
+
const noun = meta.noun || 'item';
|
|
51
|
+
const plural = meta.nounPlural || `${noun}s`;
|
|
52
|
+
const can = (action) => !!(meta.typeActions && meta.typeActions.has && meta.typeActions.has(action));
|
|
53
|
+
|
|
54
|
+
if (/^backups:/.test(ctx)) {
|
|
55
|
+
return [
|
|
56
|
+
`What backups are there for these ${plural}?`,
|
|
57
|
+
`How do I restore ${an(noun)} ${noun} from a backup?`,
|
|
58
|
+
`Which ${plural} have no backup yet?`,
|
|
59
|
+
`How do I back up ${an(noun)} ${noun} before I change it?`,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (meta.kind === 'files') {
|
|
64
|
+
return [
|
|
65
|
+
`What ${plural} are here?`,
|
|
66
|
+
`Which ${plural} changed most recently?`,
|
|
67
|
+
`How do I add ${an(noun)} ${noun}?`,
|
|
68
|
+
`How do I edit ${an(noun)} ${noun}?`,
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Only DDEV workspaces have anything "running"; a folder of git clones does not.
|
|
73
|
+
const out = can('ddev')
|
|
74
|
+
? [`What ${plural} are here, and which are running?`]
|
|
75
|
+
: [`What ${plural} are here?`];
|
|
76
|
+
out.push(`Which ${plural} have a screen or an agent running?`);
|
|
77
|
+
if (can('build')) out.push(`How do I build a new ${noun} here?`);
|
|
78
|
+
if (can('backup') && can('ddev')) out.push(`How do I back up and restore ${an(noun)} ${noun}?`);
|
|
79
|
+
if (!can('ddev')) out.push(`Which ${plural} have changes that are not committed yet?`);
|
|
80
|
+
if (can('tests')) out.push(`How do I run the tests on ${an(noun)} ${noun}?`);
|
|
81
|
+
else if (can('clone')) out.push(`How do I clone ${an(noun)} ${noun}?`);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The workspace's own list, else settings.yml's, else the defaults.
|
|
86
|
+
function suggestionsFor({ context, meta, own, global }) {
|
|
87
|
+
const chosen = cleanSuggestions(own) || cleanSuggestions(global);
|
|
88
|
+
return (chosen || defaultSuggestions(context, meta)).slice(0, MAX_SUGGESTIONS);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { suggestionsFor, defaultSuggestions, cleanSuggestions, MAX_SUGGESTIONS, MAX_LENGTH };
|