gyoshu 0.4.0 → 0.4.2

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 CHANGED
@@ -47,6 +47,8 @@ Think of it like a research lab:
47
47
 
48
48
  ## 🚀 Installation
49
49
 
50
+ ### Option 1: OpenCode Plugin (Recommended)
51
+
50
52
  Add Gyoshu to your `opencode.json`:
51
53
 
52
54
  ```json
@@ -55,12 +57,25 @@ Add Gyoshu to your `opencode.json`:
55
57
  }
56
58
  ```
57
59
 
58
- That's it! OpenCode will auto-install Gyoshu via Bun on next startup.
60
+ That's it! OpenCode will auto-install Gyoshu from npm on next startup.
61
+
62
+ ### Option 2: CLI Installer
63
+
64
+ ```bash
65
+ # Using bunx (no global install needed)
66
+ bunx gyoshu install
67
+
68
+ # Or install globally first
69
+ npm install -g gyoshu
70
+ gyoshu install
71
+ ```
72
+
73
+ The CLI automatically adds Gyoshu to your `opencode.json`.
59
74
 
60
75
  <details>
61
- <summary>📦 Development installation</summary>
76
+ <summary>📦 Development installation (for contributors)</summary>
62
77
 
63
- **Clone & link locally** (for contributors)
78
+ **Clone & link locally:**
64
79
  ```bash
65
80
  git clone https://github.com/Yeachan-Heo/My-Jogyo.git
66
81
  cd My-Jogyo && bun install
@@ -77,6 +92,10 @@ Then in your `opencode.json`:
77
92
 
78
93
  **Verify installation:**
79
94
  ```bash
95
+ # Check status via CLI
96
+ bunx gyoshu check
97
+
98
+ # Or in OpenCode
80
99
  opencode
81
100
  /gyoshu doctor
82
101
  ```
@@ -87,7 +106,7 @@ opencode
87
106
 
88
107
  > *Using Claude, GPT, Gemini, or another AI assistant with OpenCode? This section is for you.*
89
108
 
90
- **Setup is the same** — add `"gyoshu"` to your plugin array, then give your LLM the context it needs:
109
+ **Setup is the same** — run `bunx gyoshu install` or add `"gyoshu"` to your plugin array. Then give your LLM the context it needs:
91
110
 
92
111
  1. **Point your LLM to the guide:**
93
112
  > "Read `AGENTS.md` in the Gyoshu directory for full context on how to use the research tools."
@@ -327,8 +346,7 @@ Gyoshu uses your project's `.venv/` virtual environment:
327
346
 
328
347
  | Priority | Type | How It's Detected |
329
348
  |----------|------|-------------------|
330
- | 1️⃣ | Custom | `GYOSHU_PYTHON_PATH` env var |
331
- | 2️⃣ | venv | `.venv/bin/python` exists |
349
+ | 1️⃣ | venv | `.venv/bin/python` exists |
332
350
 
333
351
  **Quick setup:**
334
352
  ```bash
@@ -358,15 +376,25 @@ python3 -m venv .venv
358
376
 
359
377
  ## 🔄 Updating
360
378
 
361
- OpenCode automatically updates plugins. To force an update, remove the cached version:
379
+ Gyoshu is distributed via npm. OpenCode automatically handles plugin updates.
362
380
 
381
+ **Force update:**
363
382
  ```bash
383
+ # Clear OpenCode's cache
364
384
  rm -rf ~/.cache/opencode/node_modules/gyoshu
385
+
386
+ # Or reinstall with latest version
387
+ bunx gyoshu@latest install
365
388
  ```
366
389
 
367
390
  Then restart OpenCode.
368
391
 
369
- Verify: `opencode` then `/gyoshu doctor`
392
+ **Verify:** `opencode` then `/gyoshu doctor`
393
+
394
+ **Uninstall:**
395
+ ```bash
396
+ bunx gyoshu uninstall
397
+ ```
370
398
 
371
399
  See [CHANGELOG.md](CHANGELOG.md) for what's new.
372
400
 
package/bin/gyoshu.js ADDED
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Gyoshu CLI - Install/uninstall helper for OpenCode
5
+ *
6
+ * Usage:
7
+ * bunx gyoshu install - Add gyoshu to opencode.json
8
+ * bunx gyoshu uninstall - Remove gyoshu from opencode.json
9
+ * bunx gyoshu check - Verify installation status
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
13
+ import { join } from 'path';
14
+
15
+ const OPENCODE_CONFIG = 'opencode.json';
16
+
17
+ function findOpencodeConfig() {
18
+ // Check current directory
19
+ if (existsSync(OPENCODE_CONFIG)) {
20
+ return OPENCODE_CONFIG;
21
+ }
22
+ // Check home directory
23
+ const homeConfig = join(process.env.HOME || '', OPENCODE_CONFIG);
24
+ if (existsSync(homeConfig)) {
25
+ return homeConfig;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function readConfig(configPath) {
31
+ try {
32
+ const content = readFileSync(configPath, 'utf-8');
33
+ return JSON.parse(content);
34
+ } catch (e) {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function writeConfig(configPath, config) {
40
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
41
+ }
42
+
43
+ function install() {
44
+ let configPath = findOpencodeConfig();
45
+ let config;
46
+
47
+ if (configPath) {
48
+ config = readConfig(configPath);
49
+ if (!config) {
50
+ console.error(`Error: Could not parse ${configPath}`);
51
+ process.exit(1);
52
+ }
53
+ } else {
54
+ // Create new config in current directory
55
+ configPath = OPENCODE_CONFIG;
56
+ config = {};
57
+ console.log(`Creating new ${OPENCODE_CONFIG}...`);
58
+ }
59
+
60
+ // Ensure plugin array exists
61
+ if (!Array.isArray(config.plugin)) {
62
+ config.plugin = [];
63
+ }
64
+
65
+ // Check if already installed
66
+ if (config.plugin.includes('gyoshu')) {
67
+ console.log('Gyoshu is already installed in ' + configPath);
68
+ return;
69
+ }
70
+
71
+ // Add gyoshu
72
+ config.plugin.push('gyoshu');
73
+ writeConfig(configPath, config);
74
+
75
+ console.log('Gyoshu installed successfully!');
76
+ console.log(`Updated: ${configPath}`);
77
+ console.log('\nNext steps:');
78
+ console.log(' 1. Start OpenCode: opencode');
79
+ console.log(' 2. Run: /gyoshu doctor');
80
+ }
81
+
82
+ function uninstall() {
83
+ const configPath = findOpencodeConfig();
84
+
85
+ if (!configPath) {
86
+ console.log('No opencode.json found. Nothing to uninstall.');
87
+ return;
88
+ }
89
+
90
+ const config = readConfig(configPath);
91
+ if (!config) {
92
+ console.error(`Error: Could not parse ${configPath}`);
93
+ process.exit(1);
94
+ }
95
+
96
+ if (!Array.isArray(config.plugin) || !config.plugin.includes('gyoshu')) {
97
+ console.log('Gyoshu is not installed.');
98
+ return;
99
+ }
100
+
101
+ // Remove gyoshu
102
+ config.plugin = config.plugin.filter(p => p !== 'gyoshu');
103
+ writeConfig(configPath, config);
104
+
105
+ console.log('Gyoshu uninstalled successfully!');
106
+ console.log(`Updated: ${configPath}`);
107
+ }
108
+
109
+ function check() {
110
+ const configPath = findOpencodeConfig();
111
+
112
+ if (!configPath) {
113
+ console.log('Status: NOT INSTALLED');
114
+ console.log('No opencode.json found.');
115
+ console.log('\nTo install: bunx gyoshu install');
116
+ return;
117
+ }
118
+
119
+ const config = readConfig(configPath);
120
+ if (!config) {
121
+ console.log('Status: ERROR');
122
+ console.log(`Could not parse ${configPath}`);
123
+ return;
124
+ }
125
+
126
+ const isInstalled = Array.isArray(config.plugin) && config.plugin.includes('gyoshu');
127
+
128
+ if (isInstalled) {
129
+ console.log('Status: INSTALLED');
130
+ console.log(`Config: ${configPath}`);
131
+ console.log('\nTo verify in OpenCode: /gyoshu doctor');
132
+ } else {
133
+ console.log('Status: NOT INSTALLED');
134
+ console.log(`Config exists: ${configPath}`);
135
+ console.log('\nTo install: bunx gyoshu install');
136
+ }
137
+ }
138
+
139
+ function showHelp() {
140
+ console.log(`
141
+ Gyoshu - Scientific Research Agent for OpenCode
142
+
143
+ Usage:
144
+ gyoshu install Add gyoshu to opencode.json
145
+ gyoshu uninstall Remove gyoshu from opencode.json
146
+ gyoshu check Verify installation status
147
+ gyoshu help Show this help message
148
+
149
+ Examples:
150
+ bunx gyoshu install
151
+ npx gyoshu check
152
+
153
+ More info: https://github.com/Yeachan-Heo/My-Jogyo
154
+ `);
155
+ }
156
+
157
+ // Main
158
+ const command = process.argv[2];
159
+
160
+ switch (command) {
161
+ case 'install':
162
+ install();
163
+ break;
164
+ case 'uninstall':
165
+ uninstall();
166
+ break;
167
+ case 'check':
168
+ check();
169
+ break;
170
+ case 'help':
171
+ case '--help':
172
+ case '-h':
173
+ showHelp();
174
+ break;
175
+ default:
176
+ if (command) {
177
+ console.error(`Unknown command: ${command}\n`);
178
+ }
179
+ showHelp();
180
+ process.exit(command ? 1 : 0);
181
+ }
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "gyoshu",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Scientific research agent extension for OpenCode - turns research goals into reproducible Jupyter notebooks",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts"
9
9
  },
10
+ "bin": {
11
+ "gyoshu": "./bin/gyoshu.js"
12
+ },
10
13
  "files": [
11
- "src/"
14
+ "src/",
15
+ "bin/"
12
16
  ],
13
17
  "scripts": {
14
18
  "test": "bun test ./tests",
@@ -50,9 +54,11 @@
50
54
  },
51
55
  "devDependencies": {
52
56
  "@types/node": "^20.0.0",
57
+ "@types/sanitize-html": "^2.16.0",
53
58
  "bun-types": "latest"
54
59
  },
55
60
  "dependencies": {
61
+ "sanitize-html": "^2.17.0",
56
62
  "zod": "^3.23.0"
57
63
  }
58
64
  }
@@ -37,7 +37,7 @@ import argparse
37
37
  import socket as socket_module
38
38
  import stat
39
39
  from datetime import datetime, timezone
40
- from typing import Any, Dict, List, Optional, Callable
40
+ from typing import Any, Dict, List, Optional, Callable, Tuple
41
41
 
42
42
  # =============================================================================
43
43
  # STREAM SEPARATION
@@ -78,11 +78,10 @@ def send_response(
78
78
  id: Optional[str], result: Optional[Dict] = None, error: Optional[Dict] = None
79
79
  ) -> None:
80
80
  """Send JSON-RPC 2.0 response via protocol channel."""
81
- response: Dict[str, Any] = {"jsonrpc": JSON_RPC_VERSION}
82
-
83
- # id must be included (null for notifications, but we always have id)
84
- if id is not None:
85
- response["id"] = id
81
+ response: Dict[str, Any] = {
82
+ "jsonrpc": JSON_RPC_VERSION,
83
+ "id": id,
84
+ }
86
85
 
87
86
  if error is not None:
88
87
  response["error"] = error
@@ -247,6 +246,66 @@ def parse_markers(text: str) -> List[Dict[str, Any]]:
247
246
  return markers
248
247
 
249
248
 
249
+ # =============================================================================
250
+ # BOUNDED STRING IO (FIX-158: Prevent memory exhaustion)
251
+ # =============================================================================
252
+
253
+
254
+ def _get_max_capture_chars() -> int:
255
+ """Safely parse GYOSHU_MAX_CAPTURE_CHARS env var with validation.
256
+
257
+ Returns:
258
+ Capture limit in characters, clamped to [10KB, 100MB] range.
259
+ Default: 1MB (1048576 chars).
260
+ """
261
+ default = 1048576 # 1MB
262
+ env_val = os.getenv("GYOSHU_MAX_CAPTURE_CHARS", "")
263
+ # FIX-167: Guard against DoS via extremely long numeric strings
264
+ # 20 digits is plenty for any sane value (10^20 > 100MB limit anyway)
265
+ if len(env_val) > 20:
266
+ return default
267
+ if not env_val:
268
+ return default
269
+ try:
270
+ val = int(env_val)
271
+ # Clamp to sane range: 10KB min, 100MB max
272
+ return max(10240, min(val, 104857600))
273
+ except (ValueError, TypeError):
274
+ return default
275
+
276
+
277
+ MAX_CAPTURE_CHARS = _get_max_capture_chars()
278
+
279
+
280
+ class BoundedStringIO:
281
+ """StringIO wrapper that caps capture size to prevent memory exhaustion."""
282
+
283
+ def __init__(self, limit: int = MAX_CAPTURE_CHARS):
284
+ self._buffer = io.StringIO()
285
+ self._limit = limit
286
+ self._truncated = False
287
+
288
+ def write(self, s: str) -> int:
289
+ if self._truncated:
290
+ return len(s)
291
+ current = self._buffer.tell()
292
+ if current + len(s) > self._limit:
293
+ remaining = self._limit - current
294
+ if remaining > 0:
295
+ self._buffer.write(s[:remaining])
296
+ self._buffer.write("\n...[truncated]")
297
+ self._truncated = True
298
+ return len(s)
299
+ return self._buffer.write(s)
300
+
301
+ def getvalue(self) -> str:
302
+ return self._buffer.getvalue()
303
+
304
+ @property
305
+ def truncated(self) -> bool:
306
+ return self._truncated
307
+
308
+
250
309
  # =============================================================================
251
310
  # MEMORY UTILITIES
252
311
  # =============================================================================
@@ -401,13 +460,15 @@ def execute_code(
401
460
  Returns:
402
461
  Dict with success, stdout, stderr, exception info
403
462
  """
404
- stdout_capture = io.StringIO()
405
- stderr_capture = io.StringIO()
463
+ stdout_capture = BoundedStringIO()
464
+ stderr_capture = BoundedStringIO()
406
465
 
407
466
  result = {
408
467
  "success": False,
409
468
  "stdout": "",
410
469
  "stderr": "",
470
+ "stdout_truncated": False,
471
+ "stderr_truncated": False,
411
472
  "exception": None,
412
473
  "exception_type": None,
413
474
  "traceback": None,
@@ -457,7 +518,6 @@ def execute_code(
457
518
  )
458
519
 
459
520
  finally:
460
- # Cancel timeout
461
521
  if timeout and hasattr(signal, "SIGALRM"):
462
522
  signal.alarm(0)
463
523
  if old_handler is not None:
@@ -465,6 +525,8 @@ def execute_code(
465
525
 
466
526
  result["stdout"] = stdout_capture.getvalue()
467
527
  result["stderr"] = stderr_capture.getvalue()
528
+ result["stdout_truncated"] = stdout_capture.truncated
529
+ result["stderr_truncated"] = stderr_capture.truncated
468
530
 
469
531
  return result
470
532
 
@@ -526,6 +588,8 @@ def handle_execute(id: str, params: Dict[str, Any]) -> None:
526
588
  "success": exec_result["success"],
527
589
  "stdout": exec_result["stdout"],
528
590
  "stderr": exec_result["stderr"],
591
+ "stdout_truncated": exec_result.get("stdout_truncated", False),
592
+ "stderr_truncated": exec_result.get("stderr_truncated", False),
529
593
  "markers": markers,
530
594
  "artifacts": [], # TODO: Artifact detection from namespace
531
595
  "timing": {
@@ -589,6 +653,38 @@ HANDLERS: Dict[str, Callable[[str, Dict[str, Any]], None]] = {
589
653
  # MAIN LOOP
590
654
  # =============================================================================
591
655
 
656
+ # FIX-173: Cap JSON-RPC request line size to prevent DoS
657
+ MAX_REQUEST_LINE_BYTES = 10 * 1024 * 1024 # 10MB
658
+
659
+
660
+ def read_bounded_line(stream, max_bytes: int) -> Tuple[Optional[bytes], bool]:
661
+ """Read a line with bounded byte count.
662
+
663
+ Returns:
664
+ Tuple of (line_bytes or None if EOF, was_oversized)
665
+ - If EOF with no data: (None, False)
666
+ - If line fits in limit: (bytes, False)
667
+ - If line exceeded limit: (truncated_bytes, True) - already drained to newline
668
+ """
669
+ data = bytearray()
670
+ while len(data) < max_bytes:
671
+ char = stream.read(1)
672
+ if not char:
673
+ # EOF - return what we have
674
+ return (bytes(data) if data else None, False)
675
+ if char == b"\n":
676
+ # Normal line termination
677
+ return (bytes(data), False)
678
+ data.extend(char)
679
+
680
+ # Limit exceeded - drain rest of line (already consumes up to and including \n)
681
+ while True:
682
+ char = stream.read(1)
683
+ if not char or char == b"\n":
684
+ break
685
+ # Return truncated data with oversized flag - no peek needed by caller
686
+ return (bytes(data[:max_bytes]), True)
687
+
592
688
 
593
689
  def process_request(line: str) -> None:
594
690
  """Parse and handle a single JSON-RPC request."""
@@ -763,9 +859,19 @@ def handle_socket_connection(conn: socket_module.socket) -> None:
763
859
  try:
764
860
  _protocol_out = conn.makefile("w", buffering=1, encoding="utf-8")
765
861
 
766
- reader = conn.makefile("r", encoding="utf-8")
767
- for line in reader:
768
- line = line.strip()
862
+ reader = conn.makefile("rb")
863
+ while True:
864
+ line_bytes, was_oversized = read_bounded_line(
865
+ reader, MAX_REQUEST_LINE_BYTES
866
+ )
867
+ if line_bytes is None:
868
+ break
869
+ if was_oversized:
870
+ send_response(
871
+ None, error=make_error(ERROR_INVALID_REQUEST, "Request too large")
872
+ )
873
+ continue
874
+ line = line_bytes.decode("utf-8", errors="replace").strip()
769
875
  if not line:
770
876
  continue
771
877
  process_request(line)
@@ -789,8 +895,18 @@ def run_stdin_mode() -> None:
789
895
  sys.stderr.flush()
790
896
 
791
897
  try:
792
- for line in sys.stdin:
793
- line = line.strip()
898
+ while True:
899
+ line_bytes, was_oversized = read_bounded_line(
900
+ sys.stdin.buffer, MAX_REQUEST_LINE_BYTES
901
+ )
902
+ if line_bytes is None:
903
+ break
904
+ if was_oversized:
905
+ send_response(
906
+ None, error=make_error(ERROR_INVALID_REQUEST, "Request too large")
907
+ )
908
+ continue
909
+ line = line_bytes.decode("utf-8", errors="replace").strip()
794
910
  if not line:
795
911
  continue
796
912