wogiflow 1.0.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/.workflow/agents/reviewer.md +81 -0
- package/.workflow/agents/security.md +94 -0
- package/.workflow/agents/story-writer.md +58 -0
- package/.workflow/bridges/base-bridge.js +395 -0
- package/.workflow/bridges/claude-bridge.js +434 -0
- package/.workflow/bridges/index.js +130 -0
- package/.workflow/lib/assumption-detector.js +481 -0
- package/.workflow/lib/config-substitution.js +371 -0
- package/.workflow/lib/failure-categories.js +478 -0
- package/.workflow/state/app-map.md.template +15 -0
- package/.workflow/state/architecture.md.template +24 -0
- package/.workflow/state/component-index.json.template +5 -0
- package/.workflow/state/decisions.md.template +15 -0
- package/.workflow/state/feedback-patterns.md.template +9 -0
- package/.workflow/state/knowledge-sync.json.template +6 -0
- package/.workflow/state/progress.md.template +14 -0
- package/.workflow/state/ready.json.template +7 -0
- package/.workflow/state/request-log.md.template +14 -0
- package/.workflow/state/session-state.json.template +11 -0
- package/.workflow/state/stack.md.template +33 -0
- package/.workflow/state/testing.md.template +36 -0
- package/.workflow/templates/claude-md.hbs +257 -0
- package/.workflow/templates/correction-report.md +67 -0
- package/.workflow/templates/gemini-md.hbs +52 -0
- package/README.md +1802 -0
- package/bin/flow +205 -0
- package/lib/index.js +33 -0
- package/lib/installer.js +467 -0
- package/lib/release-channel.js +269 -0
- package/lib/skill-registry.js +526 -0
- package/lib/upgrader.js +401 -0
- package/lib/utils.js +305 -0
- package/package.json +64 -0
- package/scripts/flow +985 -0
- package/scripts/flow-adaptive-learning.js +1259 -0
- package/scripts/flow-aggregate.js +488 -0
- package/scripts/flow-archive +133 -0
- package/scripts/flow-auto-context.js +1015 -0
- package/scripts/flow-auto-learn.js +615 -0
- package/scripts/flow-bridge.js +223 -0
- package/scripts/flow-browser-suggest.js +316 -0
- package/scripts/flow-bug.js +247 -0
- package/scripts/flow-cascade.js +711 -0
- package/scripts/flow-changelog +85 -0
- package/scripts/flow-checkpoint.js +483 -0
- package/scripts/flow-cli.js +403 -0
- package/scripts/flow-code-intelligence.js +760 -0
- package/scripts/flow-complexity.js +502 -0
- package/scripts/flow-config-set.js +152 -0
- package/scripts/flow-constants.js +157 -0
- package/scripts/flow-context +152 -0
- package/scripts/flow-context-init.js +482 -0
- package/scripts/flow-context-monitor.js +384 -0
- package/scripts/flow-context-scoring.js +886 -0
- package/scripts/flow-correct.js +458 -0
- package/scripts/flow-damage-control.js +985 -0
- package/scripts/flow-deps +101 -0
- package/scripts/flow-diff.js +700 -0
- package/scripts/flow-done +151 -0
- package/scripts/flow-done.js +489 -0
- package/scripts/flow-durable-session.js +1541 -0
- package/scripts/flow-entropy-monitor.js +345 -0
- package/scripts/flow-export-profile +349 -0
- package/scripts/flow-export-scanner.js +1046 -0
- package/scripts/flow-figma-confirm.js +400 -0
- package/scripts/flow-figma-extract.js +496 -0
- package/scripts/flow-figma-generate.js +683 -0
- package/scripts/flow-figma-index.js +909 -0
- package/scripts/flow-figma-match.js +617 -0
- package/scripts/flow-figma-mcp-server.js +518 -0
- package/scripts/flow-figma-pipeline.js +414 -0
- package/scripts/flow-file-ops.js +301 -0
- package/scripts/flow-gate-confidence.js +825 -0
- package/scripts/flow-guided-edit.js +659 -0
- package/scripts/flow-health +185 -0
- package/scripts/flow-health.js +413 -0
- package/scripts/flow-hooks.js +556 -0
- package/scripts/flow-http-client.js +249 -0
- package/scripts/flow-hybrid-detect.js +167 -0
- package/scripts/flow-hybrid-interactive.js +591 -0
- package/scripts/flow-hybrid-test.js +152 -0
- package/scripts/flow-import-profile +439 -0
- package/scripts/flow-init +253 -0
- package/scripts/flow-instruction-richness.js +827 -0
- package/scripts/flow-jira-integration.js +579 -0
- package/scripts/flow-knowledge-router.js +522 -0
- package/scripts/flow-knowledge-sync.js +589 -0
- package/scripts/flow-linear-integration.js +631 -0
- package/scripts/flow-links.js +774 -0
- package/scripts/flow-log-manager.js +559 -0
- package/scripts/flow-loop-enforcer.js +1246 -0
- package/scripts/flow-loop-retry-learning.js +630 -0
- package/scripts/flow-lsp.js +923 -0
- package/scripts/flow-map-index +348 -0
- package/scripts/flow-map-sync +201 -0
- package/scripts/flow-memory-blocks.js +668 -0
- package/scripts/flow-memory-compactor.js +350 -0
- package/scripts/flow-memory-db.js +1110 -0
- package/scripts/flow-memory-sync.js +484 -0
- package/scripts/flow-metrics.js +353 -0
- package/scripts/flow-migrate-ids.js +370 -0
- package/scripts/flow-model-adapter.js +802 -0
- package/scripts/flow-model-router.js +884 -0
- package/scripts/flow-models.js +1231 -0
- package/scripts/flow-morning.js +517 -0
- package/scripts/flow-multi-approach.js +660 -0
- package/scripts/flow-new-feature +86 -0
- package/scripts/flow-onboard +1042 -0
- package/scripts/flow-orchestrate-llm.js +459 -0
- package/scripts/flow-orchestrate.js +3592 -0
- package/scripts/flow-output.js +123 -0
- package/scripts/flow-parallel-detector.js +399 -0
- package/scripts/flow-parallel-dispatch.js +987 -0
- package/scripts/flow-parallel.js +428 -0
- package/scripts/flow-pattern-enforcer.js +600 -0
- package/scripts/flow-prd-manager.js +282 -0
- package/scripts/flow-progress.js +323 -0
- package/scripts/flow-project-analyzer.js +975 -0
- package/scripts/flow-prompt-composer.js +487 -0
- package/scripts/flow-providers.js +1381 -0
- package/scripts/flow-queue.js +308 -0
- package/scripts/flow-ready +82 -0
- package/scripts/flow-ready.js +189 -0
- package/scripts/flow-regression.js +396 -0
- package/scripts/flow-response-parser.js +450 -0
- package/scripts/flow-resume.js +284 -0
- package/scripts/flow-rules-sync.js +439 -0
- package/scripts/flow-run-trace.js +718 -0
- package/scripts/flow-safety.js +587 -0
- package/scripts/flow-search +104 -0
- package/scripts/flow-security.js +481 -0
- package/scripts/flow-session-end +106 -0
- package/scripts/flow-session-end.js +437 -0
- package/scripts/flow-session-state.js +671 -0
- package/scripts/flow-setup-hooks +216 -0
- package/scripts/flow-setup-hooks.js +377 -0
- package/scripts/flow-skill-create.js +329 -0
- package/scripts/flow-skill-creator.js +572 -0
- package/scripts/flow-skill-generator.js +1046 -0
- package/scripts/flow-skill-learn.js +880 -0
- package/scripts/flow-skill-matcher.js +578 -0
- package/scripts/flow-spec-generator.js +820 -0
- package/scripts/flow-stack-wizard.js +895 -0
- package/scripts/flow-standup +162 -0
- package/scripts/flow-start +74 -0
- package/scripts/flow-start.js +235 -0
- package/scripts/flow-status +110 -0
- package/scripts/flow-status.js +301 -0
- package/scripts/flow-step-browser.js +83 -0
- package/scripts/flow-step-changelog.js +217 -0
- package/scripts/flow-step-comments.js +306 -0
- package/scripts/flow-step-complexity.js +234 -0
- package/scripts/flow-step-coverage.js +218 -0
- package/scripts/flow-step-knowledge.js +193 -0
- package/scripts/flow-step-pr-tests.js +364 -0
- package/scripts/flow-step-regression.js +89 -0
- package/scripts/flow-step-review.js +516 -0
- package/scripts/flow-step-security.js +162 -0
- package/scripts/flow-step-silent-failures.js +290 -0
- package/scripts/flow-step-simplifier.js +346 -0
- package/scripts/flow-story +105 -0
- package/scripts/flow-story.js +500 -0
- package/scripts/flow-suspend.js +252 -0
- package/scripts/flow-sync-daemon.js +654 -0
- package/scripts/flow-task-analyzer.js +606 -0
- package/scripts/flow-team-dashboard.js +748 -0
- package/scripts/flow-team-sync.js +752 -0
- package/scripts/flow-team.js +977 -0
- package/scripts/flow-tech-options.js +528 -0
- package/scripts/flow-templates.js +812 -0
- package/scripts/flow-tiered-learning.js +728 -0
- package/scripts/flow-trace +204 -0
- package/scripts/flow-transcript-chunking.js +1106 -0
- package/scripts/flow-transcript-digest.js +7918 -0
- package/scripts/flow-transcript-language.js +465 -0
- package/scripts/flow-transcript-parsing.js +1085 -0
- package/scripts/flow-transcript-stories.js +2194 -0
- package/scripts/flow-update-map +224 -0
- package/scripts/flow-utils.js +2242 -0
- package/scripts/flow-verification.js +644 -0
- package/scripts/flow-verify.js +1177 -0
- package/scripts/flow-voice-input.js +638 -0
- package/scripts/flow-watch +168 -0
- package/scripts/flow-workflow-steps.js +521 -0
- package/scripts/flow-workflow.js +1029 -0
- package/scripts/flow-worktree.js +489 -0
- package/scripts/hooks/adapters/base-adapter.js +102 -0
- package/scripts/hooks/adapters/claude-code.js +359 -0
- package/scripts/hooks/adapters/index.js +79 -0
- package/scripts/hooks/core/component-check.js +341 -0
- package/scripts/hooks/core/index.js +35 -0
- package/scripts/hooks/core/loop-check.js +241 -0
- package/scripts/hooks/core/session-context.js +294 -0
- package/scripts/hooks/core/task-gate.js +177 -0
- package/scripts/hooks/core/validation.js +230 -0
- package/scripts/hooks/entry/claude-code/post-tool-use.js +65 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +89 -0
- package/scripts/hooks/entry/claude-code/session-end.js +87 -0
- package/scripts/hooks/entry/claude-code/session-start.js +46 -0
- package/scripts/hooks/entry/claude-code/stop.js +43 -0
- package/scripts/postinstall.js +139 -0
- package/templates/browser-test-flow.json +56 -0
- package/templates/bug-report.md +43 -0
- package/templates/component-detail.md +42 -0
- package/templates/component.stories.tsx +49 -0
- package/templates/context/constraints.md +83 -0
- package/templates/context/conventions.md +177 -0
- package/templates/context/stack.md +60 -0
- package/templates/correction-report.md +90 -0
- package/templates/feature-proposal.md +35 -0
- package/templates/hybrid/_base.md +254 -0
- package/templates/hybrid/_patterns.md +45 -0
- package/templates/hybrid/create-component.md +127 -0
- package/templates/hybrid/create-file.md +56 -0
- package/templates/hybrid/create-hook.md +145 -0
- package/templates/hybrid/create-service.md +70 -0
- package/templates/hybrid/fix-bug.md +33 -0
- package/templates/hybrid/modify-file.md +55 -0
- package/templates/story.md +68 -0
- package/templates/task.json +56 -0
- package/templates/trace.md +69 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - External Context Protocol
|
|
5
|
+
*
|
|
6
|
+
* Manage external links and artifacts:
|
|
7
|
+
* - links.yaml for referencing external resources
|
|
8
|
+
* - Fetch and cache external context
|
|
9
|
+
* - Support for Notion, Figma, GitHub, and custom URLs
|
|
10
|
+
*
|
|
11
|
+
* Usage as module:
|
|
12
|
+
* const { loadLinks, fetchLink, getLinkedContext } = require('./flow-links');
|
|
13
|
+
* const links = loadLinks();
|
|
14
|
+
* const content = await fetchLink('design');
|
|
15
|
+
*
|
|
16
|
+
* Usage as CLI:
|
|
17
|
+
* flow links list # List all links
|
|
18
|
+
* flow links add <name> <url> # Add a link
|
|
19
|
+
* flow links fetch <name> # Fetch and cache link
|
|
20
|
+
* flow links show <name> # Show cached content
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const https = require('https');
|
|
26
|
+
const http = require('http');
|
|
27
|
+
const dns = require('dns');
|
|
28
|
+
const { getProjectRoot, colors: c } = require('./flow-utils');
|
|
29
|
+
|
|
30
|
+
const PROJECT_ROOT = getProjectRoot();
|
|
31
|
+
const WORKFLOW_DIR = path.join(PROJECT_ROOT, '.workflow');
|
|
32
|
+
const LINKS_PATH = path.join(WORKFLOW_DIR, 'links.yaml');
|
|
33
|
+
const LINKS_JSON_PATH = path.join(WORKFLOW_DIR, 'links.json');
|
|
34
|
+
const CACHE_DIR = path.join(WORKFLOW_DIR, 'cache', 'links');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Link types
|
|
38
|
+
*/
|
|
39
|
+
const LINK_TYPES = {
|
|
40
|
+
NOTION: 'notion',
|
|
41
|
+
FIGMA: 'figma',
|
|
42
|
+
GITHUB: 'github',
|
|
43
|
+
JIRA: 'jira',
|
|
44
|
+
LINEAR: 'linear',
|
|
45
|
+
URL: 'url',
|
|
46
|
+
FILE: 'file'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Simple YAML parser (for links.yaml)
|
|
51
|
+
*/
|
|
52
|
+
function parseYaml(content) {
|
|
53
|
+
const result = {};
|
|
54
|
+
let currentSection = null;
|
|
55
|
+
let currentItem = null;
|
|
56
|
+
|
|
57
|
+
const lines = content.split('\n');
|
|
58
|
+
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
|
|
62
|
+
// Skip comments and empty lines
|
|
63
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
64
|
+
|
|
65
|
+
// Top-level key (no indent)
|
|
66
|
+
if (!line.startsWith(' ') && !line.startsWith('\t') && trimmed.includes(':')) {
|
|
67
|
+
const [key, ...valueParts] = trimmed.split(':');
|
|
68
|
+
const value = valueParts.join(':').trim();
|
|
69
|
+
|
|
70
|
+
if (!value) {
|
|
71
|
+
// Section header
|
|
72
|
+
currentSection = key;
|
|
73
|
+
result[currentSection] = {};
|
|
74
|
+
currentItem = null;
|
|
75
|
+
} else {
|
|
76
|
+
// Simple key-value
|
|
77
|
+
result[key] = value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Nested item
|
|
81
|
+
else if (currentSection && trimmed.includes(':')) {
|
|
82
|
+
const indent = line.match(/^(\s*)/)[1].length;
|
|
83
|
+
const [key, ...valueParts] = trimmed.split(':');
|
|
84
|
+
const value = valueParts.join(':').trim();
|
|
85
|
+
|
|
86
|
+
if (indent <= 2) {
|
|
87
|
+
// New item in section
|
|
88
|
+
currentItem = key;
|
|
89
|
+
result[currentSection][currentItem] = value || {};
|
|
90
|
+
} else if (currentItem && typeof result[currentSection][currentItem] === 'object') {
|
|
91
|
+
// Property of current item
|
|
92
|
+
result[currentSection][currentItem][key] = value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generate YAML from object
|
|
102
|
+
*/
|
|
103
|
+
function toYaml(obj, indent = 0) {
|
|
104
|
+
let result = '';
|
|
105
|
+
const spaces = ' '.repeat(indent);
|
|
106
|
+
|
|
107
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
108
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
109
|
+
result += `${spaces}${key}:\n`;
|
|
110
|
+
result += toYaml(value, indent + 1);
|
|
111
|
+
} else if (Array.isArray(value)) {
|
|
112
|
+
result += `${spaces}${key}:\n`;
|
|
113
|
+
for (const item of value) {
|
|
114
|
+
result += `${spaces} - ${item}\n`;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
result += `${spaces}${key}: ${value}\n`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load links from YAML or JSON
|
|
126
|
+
*/
|
|
127
|
+
function loadLinks() {
|
|
128
|
+
// Try YAML first
|
|
129
|
+
if (fs.existsSync(LINKS_PATH)) {
|
|
130
|
+
try {
|
|
131
|
+
const content = fs.readFileSync(LINKS_PATH, 'utf-8');
|
|
132
|
+
return parseYaml(content);
|
|
133
|
+
} catch {
|
|
134
|
+
// Fall through to JSON
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Try JSON
|
|
139
|
+
if (fs.existsSync(LINKS_JSON_PATH)) {
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(fs.readFileSync(LINKS_JSON_PATH, 'utf-8'));
|
|
142
|
+
} catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Save links to YAML
|
|
152
|
+
*/
|
|
153
|
+
function saveLinks(links) {
|
|
154
|
+
const yaml = `# Wogi Flow - External Links
|
|
155
|
+
# Reference external resources (docs, designs, issues)
|
|
156
|
+
# Run: flow links fetch <name> to cache content
|
|
157
|
+
|
|
158
|
+
${toYaml(links)}`;
|
|
159
|
+
|
|
160
|
+
fs.writeFileSync(LINKS_PATH, yaml);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Detect link type from URL
|
|
165
|
+
*/
|
|
166
|
+
function detectLinkType(url) {
|
|
167
|
+
if (url.includes('notion.so') || url.includes('notion.site')) {
|
|
168
|
+
return LINK_TYPES.NOTION;
|
|
169
|
+
}
|
|
170
|
+
if (url.includes('figma.com')) {
|
|
171
|
+
return LINK_TYPES.FIGMA;
|
|
172
|
+
}
|
|
173
|
+
if (url.includes('github.com')) {
|
|
174
|
+
return LINK_TYPES.GITHUB;
|
|
175
|
+
}
|
|
176
|
+
if (url.includes('atlassian.net') || url.includes('jira.')) {
|
|
177
|
+
return LINK_TYPES.JIRA;
|
|
178
|
+
}
|
|
179
|
+
if (url.includes('linear.app')) {
|
|
180
|
+
return LINK_TYPES.LINEAR;
|
|
181
|
+
}
|
|
182
|
+
if (url.startsWith('file://') || url.startsWith('/') || url.startsWith('./')) {
|
|
183
|
+
return LINK_TYPES.FILE;
|
|
184
|
+
}
|
|
185
|
+
return LINK_TYPES.URL;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if an IP address is private/internal (SSRF protection)
|
|
190
|
+
*/
|
|
191
|
+
function isPrivateIP(ip) {
|
|
192
|
+
// Handle IPv4
|
|
193
|
+
const parts = ip.split('.').map(Number);
|
|
194
|
+
if (parts.length === 4) {
|
|
195
|
+
return (
|
|
196
|
+
ip === '127.0.0.1' ||
|
|
197
|
+
ip.startsWith('127.') ||
|
|
198
|
+
parts[0] === 10 ||
|
|
199
|
+
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
|
200
|
+
(parts[0] === 192 && parts[1] === 168) ||
|
|
201
|
+
parts[0] === 0 ||
|
|
202
|
+
ip === '255.255.255.255'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
// Handle IPv6 loopback and link-local
|
|
206
|
+
return ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc') || ip.startsWith('fd');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate URL for SSRF (resolve hostname and check IP)
|
|
211
|
+
*/
|
|
212
|
+
async function validateUrlForSSRF(urlString, options = {}) {
|
|
213
|
+
const url = new URL(urlString);
|
|
214
|
+
|
|
215
|
+
// Block non-HTTP(S) protocols
|
|
216
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
217
|
+
throw new Error(`SSRF Protection: Only HTTP(S) protocols allowed, got ${url.protocol}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Require HTTPS unless explicitly allowed
|
|
221
|
+
if (url.protocol !== 'https:' && !options.allowHttp) {
|
|
222
|
+
throw new Error('SSRF Protection: Only HTTPS URLs allowed. Use allowHttp option to override.');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Block localhost and common internal hostnames
|
|
226
|
+
const hostname = url.hostname.toLowerCase();
|
|
227
|
+
if (hostname === 'localhost' || hostname.endsWith('.local') || hostname.endsWith('.internal')) {
|
|
228
|
+
throw new Error(`SSRF Protection: Internal hostnames not allowed: ${hostname}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Resolve hostname and check IP addresses
|
|
232
|
+
try {
|
|
233
|
+
const addresses = await dns.promises.resolve4(hostname).catch(() => []);
|
|
234
|
+
const addresses6 = await dns.promises.resolve6(hostname).catch(() => []);
|
|
235
|
+
const allAddresses = [...addresses, ...addresses6];
|
|
236
|
+
|
|
237
|
+
if (allAddresses.length === 0) {
|
|
238
|
+
// Could not resolve - might be internal DNS, allow with caution
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const ip of allAddresses) {
|
|
243
|
+
if (isPrivateIP(ip)) {
|
|
244
|
+
throw new Error(`SSRF Protection: URL resolves to private IP address: ${ip}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (err.message.startsWith('SSRF')) throw err;
|
|
249
|
+
// DNS resolution failed - proceed with caution (might be valid external host)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Fetch content from URL (with SSRF protection)
|
|
255
|
+
*/
|
|
256
|
+
async function fetchUrl(url, options = {}) {
|
|
257
|
+
// Validate URL for SSRF before making request
|
|
258
|
+
await validateUrlForSSRF(url, options);
|
|
259
|
+
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
const urlObj = new URL(url);
|
|
262
|
+
const protocol = urlObj.protocol === 'https:' ? https : http;
|
|
263
|
+
|
|
264
|
+
const reqOptions = {
|
|
265
|
+
method: 'GET',
|
|
266
|
+
hostname: urlObj.hostname,
|
|
267
|
+
port: urlObj.port,
|
|
268
|
+
path: urlObj.pathname + urlObj.search,
|
|
269
|
+
headers: {
|
|
270
|
+
'User-Agent': 'Wogi-Flow/1.0',
|
|
271
|
+
...options.headers
|
|
272
|
+
},
|
|
273
|
+
timeout: options.timeout || 30000
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const req = protocol.request(reqOptions, (res) => {
|
|
277
|
+
// Handle redirects (with SSRF check)
|
|
278
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
279
|
+
// Validate redirect target for SSRF
|
|
280
|
+
validateUrlForSSRF(res.headers.location, options)
|
|
281
|
+
.then(() => fetchUrl(res.headers.location, options))
|
|
282
|
+
.then(resolve)
|
|
283
|
+
.catch(reject);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (res.statusCode !== 200) {
|
|
288
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let data = '';
|
|
293
|
+
res.on('data', chunk => data += chunk);
|
|
294
|
+
res.on('end', () => resolve(data));
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
req.on('error', reject);
|
|
298
|
+
req.on('timeout', () => reject(new Error('Request timeout')));
|
|
299
|
+
req.end();
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Extract text content from HTML
|
|
305
|
+
*/
|
|
306
|
+
function extractTextFromHtml(html) {
|
|
307
|
+
// Remove script and style tags
|
|
308
|
+
let text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
309
|
+
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
310
|
+
|
|
311
|
+
// Remove HTML tags
|
|
312
|
+
text = text.replace(/<[^>]+>/g, ' ');
|
|
313
|
+
|
|
314
|
+
// Decode common entities
|
|
315
|
+
text = text.replace(/ /g, ' ');
|
|
316
|
+
text = text.replace(/&/g, '&');
|
|
317
|
+
text = text.replace(/</g, '<');
|
|
318
|
+
text = text.replace(/>/g, '>');
|
|
319
|
+
text = text.replace(/"/g, '"');
|
|
320
|
+
|
|
321
|
+
// Clean up whitespace
|
|
322
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
323
|
+
|
|
324
|
+
return text;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Fetch and process a link
|
|
329
|
+
*/
|
|
330
|
+
async function fetchLink(name, links = null) {
|
|
331
|
+
links = links || loadLinks();
|
|
332
|
+
|
|
333
|
+
// Find the link
|
|
334
|
+
let linkData = null;
|
|
335
|
+
let section = null;
|
|
336
|
+
|
|
337
|
+
for (const [sec, items] of Object.entries(links)) {
|
|
338
|
+
if (typeof items === 'object' && items[name]) {
|
|
339
|
+
linkData = items[name];
|
|
340
|
+
section = sec;
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!linkData) {
|
|
346
|
+
throw new Error(`Link not found: ${name}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const url = typeof linkData === 'string' ? linkData : linkData.url;
|
|
350
|
+
if (!url) {
|
|
351
|
+
throw new Error(`No URL for link: ${name}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const type = detectLinkType(url);
|
|
355
|
+
let content = null;
|
|
356
|
+
|
|
357
|
+
// Fetch based on type
|
|
358
|
+
switch (type) {
|
|
359
|
+
case LINK_TYPES.FILE: {
|
|
360
|
+
const filePath = url.startsWith('file://') ? url.slice(7) : url;
|
|
361
|
+
const absPath = path.isAbsolute(filePath)
|
|
362
|
+
? filePath
|
|
363
|
+
: path.join(PROJECT_ROOT, filePath);
|
|
364
|
+
|
|
365
|
+
if (fs.existsSync(absPath)) {
|
|
366
|
+
content = fs.readFileSync(absPath, 'utf-8');
|
|
367
|
+
} else {
|
|
368
|
+
throw new Error(`File not found: ${absPath}`);
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
case LINK_TYPES.GITHUB: {
|
|
374
|
+
// Convert GitHub URL to raw content URL
|
|
375
|
+
let rawUrl = url;
|
|
376
|
+
if (url.includes('github.com') && url.includes('/blob/')) {
|
|
377
|
+
rawUrl = url
|
|
378
|
+
.replace('github.com', 'raw.githubusercontent.com')
|
|
379
|
+
.replace('/blob/', '/');
|
|
380
|
+
}
|
|
381
|
+
const html = await fetchUrl(rawUrl);
|
|
382
|
+
content = html.includes('<html') ? extractTextFromHtml(html) : html;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
default: {
|
|
387
|
+
const html = await fetchUrl(url);
|
|
388
|
+
content = extractTextFromHtml(html);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Cache the content
|
|
393
|
+
if (content) {
|
|
394
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
395
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const cacheFile = path.join(CACHE_DIR, `${name}.txt`);
|
|
399
|
+
const metadata = {
|
|
400
|
+
name,
|
|
401
|
+
url,
|
|
402
|
+
type,
|
|
403
|
+
section,
|
|
404
|
+
fetchedAt: new Date().toISOString(),
|
|
405
|
+
contentLength: content.length
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
fs.writeFileSync(cacheFile, content);
|
|
409
|
+
fs.writeFileSync(
|
|
410
|
+
path.join(CACHE_DIR, `${name}.meta.json`),
|
|
411
|
+
JSON.stringify(metadata, null, 2)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
name,
|
|
417
|
+
url,
|
|
418
|
+
type,
|
|
419
|
+
content,
|
|
420
|
+
contentLength: content?.length || 0
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Get cached content for a link
|
|
426
|
+
*/
|
|
427
|
+
function getCachedContent(name) {
|
|
428
|
+
const cacheFile = path.join(CACHE_DIR, `${name}.txt`);
|
|
429
|
+
const metaFile = path.join(CACHE_DIR, `${name}.meta.json`);
|
|
430
|
+
|
|
431
|
+
if (!fs.existsSync(cacheFile)) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const content = fs.readFileSync(cacheFile, 'utf-8');
|
|
436
|
+
let metadata = {};
|
|
437
|
+
|
|
438
|
+
if (fs.existsSync(metaFile)) {
|
|
439
|
+
try {
|
|
440
|
+
metadata = JSON.parse(fs.readFileSync(metaFile, 'utf-8'));
|
|
441
|
+
} catch {
|
|
442
|
+
// Ignore
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
content,
|
|
448
|
+
...metadata
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Add a new link
|
|
454
|
+
*/
|
|
455
|
+
function addLink(name, url, section = 'links') {
|
|
456
|
+
const links = loadLinks();
|
|
457
|
+
|
|
458
|
+
if (!links[section]) {
|
|
459
|
+
links[section] = {};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
links[section][name] = url;
|
|
463
|
+
saveLinks(links);
|
|
464
|
+
|
|
465
|
+
return links;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Remove a link
|
|
470
|
+
*/
|
|
471
|
+
function removeLink(name) {
|
|
472
|
+
const links = loadLinks();
|
|
473
|
+
|
|
474
|
+
for (const section of Object.keys(links)) {
|
|
475
|
+
if (typeof links[section] === 'object' && links[section][name]) {
|
|
476
|
+
delete links[section][name];
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
saveLinks(links);
|
|
482
|
+
|
|
483
|
+
// Remove cache
|
|
484
|
+
const cacheFile = path.join(CACHE_DIR, `${name}.txt`);
|
|
485
|
+
const metaFile = path.join(CACHE_DIR, `${name}.meta.json`);
|
|
486
|
+
|
|
487
|
+
if (fs.existsSync(cacheFile)) fs.unlinkSync(cacheFile);
|
|
488
|
+
if (fs.existsSync(metaFile)) fs.unlinkSync(metaFile);
|
|
489
|
+
|
|
490
|
+
return links;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Get all linked context for LLM prompts
|
|
495
|
+
*/
|
|
496
|
+
function getLinkedContext(linkNames = null) {
|
|
497
|
+
const links = loadLinks();
|
|
498
|
+
let context = '';
|
|
499
|
+
|
|
500
|
+
const processLink = (name) => {
|
|
501
|
+
const cached = getCachedContent(name);
|
|
502
|
+
if (cached) {
|
|
503
|
+
context += `\n## ${name}\n`;
|
|
504
|
+
context += `Source: ${cached.url || 'unknown'}\n`;
|
|
505
|
+
context += `Fetched: ${cached.fetchedAt || 'unknown'}\n\n`;
|
|
506
|
+
context += cached.content.slice(0, 5000); // Limit content
|
|
507
|
+
if (cached.content.length > 5000) {
|
|
508
|
+
context += '\n[... truncated ...]';
|
|
509
|
+
}
|
|
510
|
+
context += '\n\n';
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
if (linkNames) {
|
|
515
|
+
for (const name of linkNames) {
|
|
516
|
+
processLink(name);
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
// Get all cached links
|
|
520
|
+
if (fs.existsSync(CACHE_DIR)) {
|
|
521
|
+
const files = fs.readdirSync(CACHE_DIR)
|
|
522
|
+
.filter(f => f.endsWith('.txt'))
|
|
523
|
+
.map(f => f.replace('.txt', ''));
|
|
524
|
+
|
|
525
|
+
for (const name of files) {
|
|
526
|
+
processLink(name);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return context;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* List all links with status
|
|
536
|
+
*/
|
|
537
|
+
function listLinks() {
|
|
538
|
+
const links = loadLinks();
|
|
539
|
+
const result = [];
|
|
540
|
+
|
|
541
|
+
for (const [section, items] of Object.entries(links)) {
|
|
542
|
+
if (typeof items !== 'object') continue;
|
|
543
|
+
|
|
544
|
+
for (const [name, data] of Object.entries(items)) {
|
|
545
|
+
const url = typeof data === 'string' ? data : data.url;
|
|
546
|
+
const type = detectLinkType(url);
|
|
547
|
+
const cached = getCachedContent(name);
|
|
548
|
+
|
|
549
|
+
result.push({
|
|
550
|
+
name,
|
|
551
|
+
section,
|
|
552
|
+
url,
|
|
553
|
+
type,
|
|
554
|
+
cached: !!cached,
|
|
555
|
+
cachedAt: cached?.fetchedAt
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Initialize links file with template
|
|
565
|
+
*/
|
|
566
|
+
function initLinks() {
|
|
567
|
+
if (fs.existsSync(LINKS_PATH)) {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const template = {
|
|
572
|
+
docs: {
|
|
573
|
+
prd: './docs/PRD.md',
|
|
574
|
+
api: 'https://api.example.com/docs'
|
|
575
|
+
},
|
|
576
|
+
design: {
|
|
577
|
+
figma: 'https://figma.com/file/...'
|
|
578
|
+
},
|
|
579
|
+
issues: {
|
|
580
|
+
backlog: 'https://linear.app/...'
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
saveLinks(template);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Module exports
|
|
589
|
+
module.exports = {
|
|
590
|
+
LINK_TYPES,
|
|
591
|
+
loadLinks,
|
|
592
|
+
saveLinks,
|
|
593
|
+
addLink,
|
|
594
|
+
removeLink,
|
|
595
|
+
fetchLink,
|
|
596
|
+
getCachedContent,
|
|
597
|
+
getLinkedContext,
|
|
598
|
+
listLinks,
|
|
599
|
+
initLinks,
|
|
600
|
+
detectLinkType
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// CLI Handler
|
|
604
|
+
if (require.main === module) {
|
|
605
|
+
const args = process.argv.slice(2);
|
|
606
|
+
const command = args[0];
|
|
607
|
+
|
|
608
|
+
async function main() {
|
|
609
|
+
switch (command) {
|
|
610
|
+
case 'list': {
|
|
611
|
+
const links = listLinks();
|
|
612
|
+
|
|
613
|
+
if (links.length === 0) {
|
|
614
|
+
console.log(`${c.dim}No links configured.${c.reset}`);
|
|
615
|
+
console.log(`${c.dim}Run "flow links init" to create template.${c.reset}`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
console.log(`\n${c.cyan}${c.bold}External Links${c.reset}\n`);
|
|
620
|
+
|
|
621
|
+
// Group by section
|
|
622
|
+
const sections = {};
|
|
623
|
+
for (const link of links) {
|
|
624
|
+
if (!sections[link.section]) {
|
|
625
|
+
sections[link.section] = [];
|
|
626
|
+
}
|
|
627
|
+
sections[link.section].push(link);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
for (const [section, items] of Object.entries(sections)) {
|
|
631
|
+
console.log(`${c.bold}${section}${c.reset}`);
|
|
632
|
+
for (const link of items) {
|
|
633
|
+
const status = link.cached
|
|
634
|
+
? `${c.green}✓ cached${c.reset}`
|
|
635
|
+
: `${c.dim}not cached${c.reset}`;
|
|
636
|
+
console.log(` ${link.name}: ${c.dim}${link.url.slice(0, 50)}...${c.reset} [${status}]`);
|
|
637
|
+
}
|
|
638
|
+
console.log('');
|
|
639
|
+
}
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
case 'add': {
|
|
644
|
+
const name = args[1];
|
|
645
|
+
const url = args[2];
|
|
646
|
+
const section = args[3] || 'links';
|
|
647
|
+
|
|
648
|
+
if (!name || !url) {
|
|
649
|
+
console.error(`${c.red}Error: Name and URL required${c.reset}`);
|
|
650
|
+
console.log(`${c.dim}Usage: flow links add <name> <url> [section]${c.reset}`);
|
|
651
|
+
process.exit(1);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
addLink(name, url, section);
|
|
655
|
+
console.log(`${c.green}✅ Added link: ${name}${c.reset}`);
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
case 'remove': {
|
|
660
|
+
const name = args[1];
|
|
661
|
+
if (!name) {
|
|
662
|
+
console.error(`${c.red}Error: Link name required${c.reset}`);
|
|
663
|
+
process.exit(1);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
removeLink(name);
|
|
667
|
+
console.log(`${c.green}✅ Removed link: ${name}${c.reset}`);
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
case 'fetch': {
|
|
672
|
+
const name = args[1];
|
|
673
|
+
if (!name) {
|
|
674
|
+
console.error(`${c.red}Error: Link name required${c.reset}`);
|
|
675
|
+
process.exit(1);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
console.log(`${c.cyan}Fetching ${name}...${c.reset}`);
|
|
679
|
+
try {
|
|
680
|
+
const result = await fetchLink(name);
|
|
681
|
+
console.log(`${c.green}✅ Fetched and cached: ${result.contentLength} chars${c.reset}`);
|
|
682
|
+
} catch (err) {
|
|
683
|
+
console.error(`${c.red}Error: ${err.message}${c.reset}`);
|
|
684
|
+
process.exit(1);
|
|
685
|
+
}
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
case 'show': {
|
|
690
|
+
const name = args[1];
|
|
691
|
+
if (!name) {
|
|
692
|
+
console.error(`${c.red}Error: Link name required${c.reset}`);
|
|
693
|
+
process.exit(1);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const cached = getCachedContent(name);
|
|
697
|
+
if (!cached) {
|
|
698
|
+
console.error(`${c.yellow}Not cached. Run: flow links fetch ${name}${c.reset}`);
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
console.log(`\n${c.cyan}${c.bold}${name}${c.reset}`);
|
|
703
|
+
console.log(`${c.dim}URL: ${cached.url}${c.reset}`);
|
|
704
|
+
console.log(`${c.dim}Fetched: ${cached.fetchedAt}${c.reset}`);
|
|
705
|
+
console.log(`${c.dim}Length: ${cached.contentLength} chars${c.reset}`);
|
|
706
|
+
console.log(`${'─'.repeat(60)}`);
|
|
707
|
+
console.log(cached.content.slice(0, 2000));
|
|
708
|
+
if (cached.content.length > 2000) {
|
|
709
|
+
console.log(`\n${c.dim}... (${cached.content.length - 2000} more chars)${c.reset}`);
|
|
710
|
+
}
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
case 'init': {
|
|
715
|
+
const created = initLinks();
|
|
716
|
+
if (created) {
|
|
717
|
+
console.log(`${c.green}✅ Created links.yaml template${c.reset}`);
|
|
718
|
+
} else {
|
|
719
|
+
console.log(`${c.yellow}links.yaml already exists${c.reset}`);
|
|
720
|
+
}
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
case 'context': {
|
|
725
|
+
const linkNames = args.slice(1);
|
|
726
|
+
const context = getLinkedContext(linkNames.length > 0 ? linkNames : null);
|
|
727
|
+
|
|
728
|
+
if (!context) {
|
|
729
|
+
console.log(`${c.dim}No cached content. Run "flow links fetch <name>" first.${c.reset}`);
|
|
730
|
+
} else {
|
|
731
|
+
console.log(context);
|
|
732
|
+
}
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
default: {
|
|
737
|
+
console.log(`
|
|
738
|
+
${c.cyan}Wogi Flow - External Context Protocol${c.reset}
|
|
739
|
+
|
|
740
|
+
${c.bold}Usage:${c.reset}
|
|
741
|
+
flow links list List all configured links
|
|
742
|
+
flow links add <name> <url> Add a new link
|
|
743
|
+
flow links remove <name> Remove a link
|
|
744
|
+
flow links fetch <name> Fetch and cache link content
|
|
745
|
+
flow links show <name> Show cached content
|
|
746
|
+
flow links context [names...] Get linked context for prompts
|
|
747
|
+
flow links init Create template links.yaml
|
|
748
|
+
|
|
749
|
+
${c.bold}Supported Sources:${c.reset}
|
|
750
|
+
- Notion pages
|
|
751
|
+
- Figma files
|
|
752
|
+
- GitHub files/repos
|
|
753
|
+
- Jira/Linear issues
|
|
754
|
+
- Any URL (HTML extracted)
|
|
755
|
+
- Local files
|
|
756
|
+
|
|
757
|
+
${c.bold}Configuration:${c.reset}
|
|
758
|
+
Edit .workflow/links.yaml:
|
|
759
|
+
|
|
760
|
+
docs:
|
|
761
|
+
prd: ./docs/PRD.md
|
|
762
|
+
api: https://api.example.com/docs
|
|
763
|
+
design:
|
|
764
|
+
figma: https://figma.com/file/...
|
|
765
|
+
`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
main().catch(err => {
|
|
771
|
+
console.error(`${c.red}Error: ${err.message}${c.reset}`);
|
|
772
|
+
process.exit(1);
|
|
773
|
+
});
|
|
774
|
+
}
|