@astrosheep/square 0.3.92 → 0.3.93

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.
@@ -1,11 +1,3 @@
1
- export interface HarnessLink {
2
- source: string;
3
- target: string;
4
- kind?: 'skill' | 'extension';
5
- }
6
- export declare function installHarnessLinks(links: HarnessLink[], force?: boolean): string[];
7
- export declare function uninstallHarnessLinks(links: HarnessLink[]): string[];
8
- export declare function doctorHarnessLinks(links: HarnessLink[]): string[];
9
1
  export type OpenCodeCommandRunner = (homeDir: string, args: string[]) => {
10
2
  status: number;
11
3
  stdout: string;
@@ -13,6 +5,5 @@ export type OpenCodeCommandRunner = (homeDir: string, args: string[]) => {
13
5
  };
14
6
  export declare function installOpenCodePlugin(homeDir: string, force?: boolean, run?: OpenCodeCommandRunner): string[];
15
7
  export declare function uninstallOpenCodePlugin(homeDir: string): string[];
16
- /** Verify that OpenCode accepts its resolved runtime configuration after links are installed. */
8
+ /** Config sources prove configuration, not that a running session loaded the plugin. */
17
9
  export declare function verifyOpenCodeRuntime(homeDir: string, run?: OpenCodeCommandRunner): string;
18
- export declare function skillLinks(homeDir?: string, parents?: Array<'.claude' | '.agents'>): HarnessLink[];
@@ -1,79 +1,10 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
3
+ import { isDeepStrictEqual } from 'node:util';
5
4
  import crossSpawn from 'cross-spawn';
5
+ import { applyEdits, createScanner, findNodeAtLocation, modify, parse, parseTree } from 'jsonc-parser';
6
6
  import { SQUARE_IDENTITY } from './identity.js';
7
7
  import { truncateExternalDiagnostic } from './presentation.js';
8
- function packageRoot() {
9
- // Emitted modules live in dist; package assets are one level above them.
10
- return fileURLToPath(new URL('../', import.meta.url));
11
- }
12
- export function installHarnessLinks(links, force = false) {
13
- const prepared = links.map((link) => {
14
- if (!fs.existsSync(link.source)) {
15
- throw new Error(`Harness link source is missing: ${link.source}`);
16
- }
17
- const existing = (() => {
18
- try {
19
- return fs.lstatSync(link.target);
20
- }
21
- catch (error) {
22
- if (error.code === 'ENOENT')
23
- return undefined;
24
- throw error;
25
- }
26
- })();
27
- return {
28
- ...link,
29
- existing,
30
- sourceIsDirectory: fs.statSync(link.source).isDirectory(),
31
- alreadyManaged: existing !== undefined && sameLink(link.source, link.target),
32
- };
33
- });
34
- for (const { target, existing, alreadyManaged } of prepared) {
35
- if (existing !== undefined && !alreadyManaged && !force)
36
- throw new Error(`Refusing to overwrite existing link: ${target}\nPass -f to replace it.`);
37
- }
38
- for (const { source, target, existing, sourceIsDirectory, alreadyManaged } of prepared) {
39
- if (alreadyManaged)
40
- continue;
41
- fs.mkdirSync(path.dirname(target), { recursive: true });
42
- if (existing !== undefined)
43
- fs.rmSync(target, { recursive: true, force: true });
44
- const symlinkType = os.platform() === 'win32' && sourceIsDirectory ? 'junction' : 'file';
45
- fs.symlinkSync(source, target, symlinkType);
46
- }
47
- return prepared.map(({ target }) => target);
48
- }
49
- function sameLink(source, target) {
50
- try {
51
- return fs.realpathSync(source) === fs.realpathSync(target);
52
- }
53
- catch {
54
- return false;
55
- }
56
- }
57
- export function uninstallHarnessLinks(links) {
58
- const removed = [];
59
- for (const link of links) {
60
- try {
61
- if (fs.lstatSync(link.target).isSymbolicLink() && sameLink(link.source, link.target)) {
62
- fs.rmSync(link.target, { force: true });
63
- removed.push(link.target);
64
- }
65
- }
66
- catch {
67
- // A missing or user-owned target is preserved.
68
- }
69
- }
70
- return removed;
71
- }
72
- export function doctorHarnessLinks(links) {
73
- return links.map((link) => sameLink(link.source, link.target)
74
- ? `✓ Square ${link.kind ?? 'link'} ${link.target}`
75
- : `○ Square ${link.kind ?? 'link'} missing ${link.target}`);
76
- }
77
8
  function runOpenCode(homeDir, args) {
78
9
  const prefix = (() => {
79
10
  try {
@@ -94,39 +25,150 @@ function runOpenCode(homeDir, args) {
94
25
  return { status: result.status ?? 1, stdout: result.stdout || '', stderr: result.stderr || '' };
95
26
  }
96
27
  export function installOpenCodePlugin(homeDir, force = false, run = runOpenCode) {
97
- // OpenCode v2's `plugin add` is idempotent and has no --global/--force flags.
28
+ // Explicit `square install` replaces owned pins/duplicates with one unpinned
29
+ // current-field entry; updating an exact old pin otherwise succeeds unchanged.
98
30
  void force;
99
- const args = ['plugin', 'add', SQUARE_IDENTITY.packageName];
31
+ const configs = readOpenCodeConfigs(homeDir);
32
+ const entries = configs.flatMap((config) => config.entries);
33
+ const options = entries.map((entry) => entry.options).filter((value) => value !== undefined);
34
+ if (options.some((value) => !isDeepStrictEqual(value, options[0]))) {
35
+ throw new Error('Conflicting Square plugin options in OpenCode configuration; reconcile the owned plugin/plugins entries before installing.');
36
+ }
37
+ const canonical = options.length ? { package: SQUARE_IDENTITY.packageName, options: options[0] } : SQUARE_IDENTITY.packageName;
38
+ const targets = configs.length ? configs : [{ target: openCodeConfigPaths(homeDir)[0], source: '{}', entries: [] }];
39
+ const alreadyCanonical = entries.length === 1 && configs[0]?.entries[0]?.field === 'plugins'
40
+ && isDeepStrictEqual(entries[0].value, canonical);
41
+ const changes = alreadyCanonical ? [] : targets.map(({ target, source, entries }, index) => {
42
+ let next = source;
43
+ for (const { field, index } of entries.toReversed())
44
+ next = removeOpenCodeEntry(next, field, index);
45
+ if (index === 0)
46
+ next = applyEdits(next, modify(next, ['plugins', -1], canonical, {}));
47
+ return { target, source, next };
48
+ }).filter(({ source, next }) => source !== next);
49
+ // Appending just our element avoids native add's whole-array rewrite, which
50
+ // drops comments belonging to unrelated plugins. Native add still owns npm.
51
+ for (const { target, next } of changes) {
52
+ fs.mkdirSync(path.dirname(target), { recursive: true });
53
+ fs.writeFileSync(target, next);
54
+ }
55
+ if (!entries.some(({ spec }) => spec === SQUARE_IDENTITY.packageName)) {
56
+ try {
57
+ openCodeCommand(homeDir, ['plugin', 'add', SQUARE_IDENTITY.packageName], run, 'install');
58
+ }
59
+ catch (error) {
60
+ // Undo only our preparation, never files the native command/user changed.
61
+ for (const { target, source, next } of changes) {
62
+ if (fs.existsSync(target) && fs.readFileSync(target, 'utf8') === next) {
63
+ if (configs.length)
64
+ fs.writeFileSync(target, source);
65
+ else
66
+ fs.rmSync(target);
67
+ }
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ // Even add can reuse an older cached package. Always request the latest.
73
+ openCodeCommand(homeDir, ['plugin', 'update', SQUARE_IDENTITY.packageName], run, 'update');
74
+ return [SQUARE_IDENTITY.packageName];
75
+ }
76
+ function openCodeCommand(homeDir, args, run, operation) {
100
77
  const result = run(homeDir, args);
101
78
  if (result.status !== 0)
102
- throw new Error(`OpenCode plugin install failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
103
- return [SQUARE_IDENTITY.packageName];
79
+ throw new Error(`OpenCode ${operation} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
80
+ return result.stdout;
104
81
  }
105
- function configPath(homeDir) {
82
+ function openCodeConfigPaths(homeDir) {
106
83
  const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homeDir, '.config');
107
- return path.join(configHome, 'opencode', 'opencode.jsonc');
84
+ const root = path.resolve(configHome, 'opencode');
85
+ return [root, path.join(root, '.opencode')].flatMap((directory) => ['opencode.json', 'opencode.jsonc'].map((name) => path.join(directory, name)));
86
+ }
87
+ function readOpenCodeConfigs(homeDir) {
88
+ return openCodeConfigPaths(homeDir).flatMap((target) => {
89
+ let source;
90
+ try {
91
+ source = fs.readFileSync(target, 'utf8');
92
+ }
93
+ catch (error) {
94
+ if (error.code === 'ENOENT')
95
+ return [];
96
+ throw error;
97
+ }
98
+ const errors = [];
99
+ const config = parse(source, errors, { allowTrailingComma: true });
100
+ if (errors.length || typeof config !== 'object' || config === null || Array.isArray(config)) {
101
+ throw new Error(`Invalid OpenCode configuration: ${target}`);
102
+ }
103
+ const entries = ['plugin', 'plugins'].flatMap((field) => {
104
+ const plugins = config[field];
105
+ if (plugins === undefined)
106
+ return [];
107
+ if (!Array.isArray(plugins))
108
+ throw new Error(`Invalid OpenCode ${field} configuration: ${target}`);
109
+ return plugins.flatMap((entry, index) => {
110
+ const spec = openCodePluginSpec(entry);
111
+ const options = Array.isArray(entry) ? entry[1]
112
+ : typeof entry === 'object' && entry !== null ? entry.options : undefined;
113
+ return spec === undefined ? [] : [{ field, index, spec, options, value: entry }];
114
+ });
115
+ });
116
+ return [{ target, source, entries }];
117
+ });
108
118
  }
109
- function removeConfiguredPlugin(homeDir) {
110
- const target = configPath(homeDir);
111
- let source;
119
+ export function uninstallOpenCodePlugin(homeDir) {
120
+ // Native remove only handles `plugins` in one file. Delete our elements in
121
+ // both fields/files, from the end so indexes remain stable, keeping JSONC.
122
+ const changes = readOpenCodeConfigs(homeDir).filter(({ entries }) => entries.length).map(({ target, source, entries }) => {
123
+ for (const { field, index } of entries.reverse()) {
124
+ source = removeOpenCodeEntry(source, field, index);
125
+ }
126
+ return { target, source };
127
+ });
128
+ for (const { target, source } of changes)
129
+ fs.writeFileSync(target, source);
130
+ return changes.length ? [SQUARE_IDENTITY.packageName] : [];
131
+ }
132
+ // jsonc-parser's array `modify` also removes intervening comments. Use its AST
133
+ // and scanner to remove only the owned value and one separator, not the trivia.
134
+ function removeOpenCodeEntry(source, field, index) {
135
+ const array = findNodeAtLocation(parseTree(source), [field]);
136
+ const node = array.children[index];
137
+ const scanner = createScanner(source, true);
138
+ scanner.setPosition(node.offset + node.length);
139
+ let comma = scanner.scan() === 5 /* SyntaxKind.CommaToken */ ? scanner.getTokenOffset() : undefined;
140
+ if (comma === undefined && index > 0) {
141
+ const previous = array.children[index - 1];
142
+ scanner.setPosition(previous.offset + previous.length);
143
+ if (scanner.scan() === 5 /* SyntaxKind.CommaToken */)
144
+ comma = scanner.getTokenOffset();
145
+ }
146
+ return applyEdits(source, [
147
+ { offset: node.offset, length: node.length, content: '' },
148
+ ...(comma === undefined ? [] : [{ offset: comma, length: 1, content: '' }]),
149
+ ]);
150
+ }
151
+ function openCodePluginSpec(entry) {
152
+ const spec = typeof entry === 'string' ? entry : Array.isArray(entry) ? entry[0]
153
+ : typeof entry === 'object' && entry !== null && 'package' in entry ? entry.package : undefined;
154
+ return typeof spec === 'string' && (spec === SQUARE_IDENTITY.packageName || spec.startsWith(`${SQUARE_IDENTITY.packageName}@`))
155
+ ? spec : undefined;
156
+ }
157
+ function parseOpenCodeConfig(stdout) {
158
+ let sources;
112
159
  try {
113
- source = fs.readFileSync(target, 'utf8');
160
+ sources = JSON.parse(stdout);
114
161
  }
115
162
  catch {
116
- return false;
163
+ throw new Error('OpenCode debug config returned invalid JSON');
117
164
  }
118
- const escaped = SQUARE_IDENTITY.packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
119
- const packageLine = new RegExp(`^\\s*"${escaped}(?:@[^"\\n]+)?"\\s*,?\\s*$`, 'm');
120
- const next = source.replace(packageLine, '');
121
- if (next === source)
122
- return false;
123
- fs.writeFileSync(target, next);
124
- return true;
125
- }
126
- export function uninstallOpenCodePlugin(homeDir) {
127
- return removeConfiguredPlugin(homeDir) ? [SQUARE_IDENTITY.packageName] : [];
165
+ if (!Array.isArray(sources) || sources.some((source) => source === null || typeof source !== 'object'
166
+ || !['document', 'directory'].includes(source.type))) {
167
+ throw new Error('OpenCode debug config returned unexpected configuration sources');
168
+ }
169
+ return sources;
128
170
  }
129
- /** Verify that OpenCode accepts its resolved runtime configuration after links are installed. */
171
+ /** Config sources prove configuration, not that a running session loaded the plugin. */
130
172
  export function verifyOpenCodeRuntime(homeDir, run = runOpenCode) {
131
173
  try {
132
174
  const result = run(homeDir, ['debug', 'config']);
@@ -134,28 +176,21 @@ export function verifyOpenCodeRuntime(homeDir, run = runOpenCode) {
134
176
  const diagnostic = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
135
177
  return `✕ OpenCode debug config failed: ${truncateExternalDiagnostic(diagnostic)}`;
136
178
  }
137
- let config;
179
+ let sources;
138
180
  try {
139
- config = JSON.parse(result.stdout);
181
+ sources = parseOpenCodeConfig(result.stdout);
140
182
  }
141
- catch {
142
- return '✕ OpenCode debug config returned invalid JSON';
183
+ catch (error) {
184
+ return `✕ ${error.message}`;
143
185
  }
144
- const plugin = config.config?.plugin;
145
- const expected = SQUARE_IDENTITY.packageName;
146
- if (Array.isArray(plugin) && plugin.includes(expected))
147
- return '✓ OpenCode npm plugin loaded';
148
- return `○ OpenCode npm plugin not loaded: ${expected}`;
186
+ const configured = sources.some((source) => source.type === 'document'
187
+ && Array.isArray(source.info?.plugins) && source.info.plugins.some((entry) => openCodePluginSpec(entry) !== undefined));
188
+ return configured
189
+ ? '✓ OpenCode npm plugin configured (runtime loading not verified)'
190
+ : `○ OpenCode npm plugin not configured: ${SQUARE_IDENTITY.packageName}`;
149
191
  }
150
192
  catch (error) {
151
193
  const diagnostic = error instanceof Error ? error.message : String(error);
152
194
  return `○ OpenCode runtime unavailable (${truncateExternalDiagnostic(diagnostic)})`;
153
195
  }
154
196
  }
155
- export function skillLinks(homeDir = os.homedir(), parents = ['.claude', '.agents']) {
156
- return parents.flatMap((parent) => ['square'].map((name) => ({
157
- source: path.join(packageRoot(), 'skills', name),
158
- target: path.join(homeDir, parent, 'skills', name),
159
- kind: 'skill',
160
- })));
161
- }
package/dist/harness.js CHANGED
@@ -5,7 +5,7 @@ import { truncateExternalDiagnostic } from './presentation.js';
5
5
  import { doctorClaudePlugin, installClaudePlugin, uninstallClaudePlugin, } from './harness-claude.js';
6
6
  import { doctorCodexPlugin, installCodexPlugin, uninstallCodexPlugin, } from './harness-codex.js';
7
7
  import { doctorPiPackage, installPiPackage, uninstallPiPackage, } from './harness-pi.js';
8
- import { doctorHarnessLinks, installHarnessLinks, installOpenCodePlugin, skillLinks, uninstallOpenCodePlugin, uninstallHarnessLinks, verifyOpenCodeRuntime, } from './harness-links.js';
8
+ import { installOpenCodePlugin, uninstallOpenCodePlugin, verifyOpenCodeRuntime, } from './harness-links.js';
9
9
  function result(lines, notes = []) {
10
10
  return { lines, notes };
11
11
  }
@@ -18,9 +18,6 @@ async function doctorHost(label, inspect) {
18
18
  return result([`○ ${label} doctor unavailable (${truncateExternalDiagnostic(diagnostic)})`]);
19
19
  }
20
20
  }
21
- function openCodeLinks(homeDir) {
22
- return skillLinks(homeDir, ['.agents']);
23
- }
24
21
  function readableSquarePath(squarePath) {
25
22
  if (squarePath === undefined)
26
23
  return false;
@@ -67,15 +64,9 @@ const TARGETS = [
67
64
  {
68
65
  name: 'opencode',
69
66
  capabilities: ['install', 'uninstall', 'doctor'],
70
- install: async ({ homeDir, force }) => result([
71
- ...installOpenCodePlugin(homeDir, force),
72
- ...installHarnessLinks(openCodeLinks(homeDir), force),
73
- ]),
74
- uninstall: ({ homeDir }) => result([
75
- ...uninstallOpenCodePlugin(homeDir),
76
- ...uninstallHarnessLinks(openCodeLinks(homeDir)),
77
- ]),
78
- doctor: ({ homeDir }) => result([verifyOpenCodeRuntime(homeDir), ...doctorHarnessLinks(openCodeLinks(homeDir))]),
67
+ install: ({ homeDir, force }) => result(installOpenCodePlugin(homeDir, force)),
68
+ uninstall: ({ homeDir }) => result(uninstallOpenCodePlugin(homeDir)),
69
+ doctor: ({ homeDir }) => result([verifyOpenCodeRuntime(homeDir)]),
79
70
  },
80
71
  {
81
72
  name: 'pi',
@@ -3,7 +3,8 @@ import os from 'node:os';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { SquareError } from './model.js';
5
5
  export function parseSquareLocation(value) {
6
- if (!/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value))
6
+ // Windows roots include drive-relative prefixes such as D:.
7
+ if (path.win32.parse(value).root !== '' || !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value))
7
8
  return { kind: 'local', path: value };
8
9
  // Parse the raw components, before URL dot-segment normalization or host lowercasing.
9
10
  const match = /^stdio:\/\/([A-Za-z0-9_-]+)\/([^/?#]+)$/.exec(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.92",
3
+ "version": "0.3.93",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -80,6 +80,7 @@
80
80
  "dependencies": {
81
81
  "@modelcontextprotocol/sdk": "^1.32.0",
82
82
  "cross-spawn": "^7.0.6",
83
+ "jsonc-parser": "^3.3.1",
83
84
  "zod": "^4.6.5"
84
85
  },
85
86
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.92",
3
+ "version": "0.3.93",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.92",
3
+ "version": "0.3.93",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"