shortcutxl 0.2.14 → 0.2.15
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
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
# ShortcutXL
|
|
2
2
|
|
|
3
|
-
An AI agent that lives on your computer and has Excel superpowers. Made by the
|
|
3
|
+
An AI agent that lives on your computer and has Excel superpowers. Made by the [Shortcut](https://shortcut.ai) team.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
### 1. Open Command Prompt or PowerShell and install Node.js
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
winget install OpenJS.NodeJS.LTS
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### 2. Install ShortcutXL
|
|
4
14
|
|
|
5
15
|
```bash
|
|
6
16
|
npm install -g shortcutxl
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### 3. Launch ShortcutXL
|
|
20
|
+
|
|
21
|
+
Type `shortcut` in your terminal:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
7
24
|
shortcut
|
|
8
25
|
```
|
|
9
26
|
|
|
10
|
-
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- **Windows 10/11** with **Excel 2016+** (64-bit)
|
|
30
|
+
- **Node.js >= 20**
|
|
11
31
|
|
|
12
32
|
## Capabilities
|
|
13
33
|
|
|
@@ -19,8 +39,3 @@ shortcut
|
|
|
19
39
|
- **Extensible** — Integrate any API or data source by adding a skill file or a custom tool extension.
|
|
20
40
|
- **User-defined functions (UDFs)** — Custom Excel formulas powered by Python for live data, calculations, or database queries.
|
|
21
41
|
- **External data connections** — ODBC, OLE DB, QueryTables, Power Query.
|
|
22
|
-
|
|
23
|
-
## Prerequisites
|
|
24
|
-
|
|
25
|
-
- **Windows 10/11** with **Excel 2016+** (64-bit)
|
|
26
|
-
- **Node.js >= 20** — If missing: `winget install OpenJS.NodeJS.LTS`
|
package/package.json
CHANGED
|
@@ -4,8 +4,8 @@ C calls dispatch_request(body_json) for every POST /exec.
|
|
|
4
4
|
This function always returns valid JSON. C never builds JSON.
|
|
5
5
|
|
|
6
6
|
Two request shapes:
|
|
7
|
-
{"code": "..."}
|
|
8
|
-
{"control": "revert"|"cleanup"
|
|
7
|
+
{"code": "..."} → exec_and_respond
|
|
8
|
+
{"control": "revert"|"cleanup"|"navigate", ...} → handle_control
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import io
|
|
@@ -20,7 +20,9 @@ from shortcut_xl._diff_highlight import (
|
|
|
20
20
|
revert_by_tx,
|
|
21
21
|
)
|
|
22
22
|
from shortcut_xl._managed import run_managed
|
|
23
|
+
from shortcut_xl._navigate import clear_navigate_cursor, navigate_to_cell
|
|
23
24
|
from shortcut_xl._threading import _run_on_main
|
|
25
|
+
from shortcut_xl.api.utils.helpers import address_to_index
|
|
24
26
|
|
|
25
27
|
_MAX_DIFF_JSON_CELLS = 50
|
|
26
28
|
|
|
@@ -44,12 +46,41 @@ def _format_cell_value(v):
|
|
|
44
46
|
return str(v)
|
|
45
47
|
|
|
46
48
|
|
|
49
|
+
def _cell_sort_key(c):
|
|
50
|
+
"""Sort key: (sheet, row, col) using address_to_index."""
|
|
51
|
+
addr = c.get('address', '')
|
|
52
|
+
try:
|
|
53
|
+
row, col = address_to_index(addr)
|
|
54
|
+
except (ValueError, AttributeError):
|
|
55
|
+
row, col = 0, 0
|
|
56
|
+
return (c.get('sheet', ''), row, col)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sort_dirty(dirty):
|
|
60
|
+
"""Sort dirty cells by (sheet, row, column) for row-wise display.
|
|
61
|
+
|
|
62
|
+
Applied once before both _build_diff and highlight_changes so the
|
|
63
|
+
entire pipeline sees a consistent order.
|
|
64
|
+
"""
|
|
65
|
+
return sorted(dirty, key=_cell_sort_key)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cell_ref(c):
|
|
69
|
+
"""Build a full cell reference string from a dirty cell dict."""
|
|
70
|
+
wb = c.get('workbook', '')
|
|
71
|
+
prefix = f"[{wb}]" if wb else ''
|
|
72
|
+
return f"{prefix}{c.get('sheet', '')}!{c.get('address', '')}"
|
|
73
|
+
|
|
74
|
+
|
|
47
75
|
def _build_diff(dirty):
|
|
48
|
-
"""Build structured diff payload for the TUI approval component.
|
|
76
|
+
"""Build structured diff payload for the TUI approval component.
|
|
77
|
+
|
|
78
|
+
Expects *dirty* to be pre-sorted by _sort_dirty. One row per cell.
|
|
79
|
+
"""
|
|
49
80
|
cells = []
|
|
50
81
|
for c in dirty[:_MAX_DIFF_JSON_CELLS]:
|
|
51
82
|
entry = {
|
|
52
|
-
'
|
|
83
|
+
'address': _cell_ref(c),
|
|
53
84
|
'before': _format_cell_value(c.get('oldValue')),
|
|
54
85
|
'after': _format_cell_value(c.get('value')),
|
|
55
86
|
}
|
|
@@ -109,6 +140,7 @@ def exec_and_respond(code_str, *, auto_approve=False):
|
|
|
109
140
|
|
|
110
141
|
response = {'ok': True, 'output': output}
|
|
111
142
|
if dirty:
|
|
143
|
+
dirty = _sort_dirty(dirty)
|
|
112
144
|
response['diff'] = _build_diff(dirty)
|
|
113
145
|
# Only apply CF highlights when the user may review changes.
|
|
114
146
|
if not auto_approve:
|
|
@@ -136,12 +168,21 @@ def handle_control(request):
|
|
|
136
168
|
if action == 'revert':
|
|
137
169
|
if cf_tx_id is None:
|
|
138
170
|
return json.dumps({'ok': False, 'error': "revert requires cfTxId"})
|
|
171
|
+
_run_on_main(clear_navigate_cursor)
|
|
139
172
|
n = _run_on_main(lambda: revert_by_tx(xl_app(), cf_tx_id))
|
|
140
173
|
if n == 0:
|
|
141
174
|
return json.dumps({'ok': True, 'output': 'Nothing to revert (transaction not found or already cleaned up).'})
|
|
142
175
|
return json.dumps({'ok': True, 'output': f"Reverted {n} cell{'s' if n != 1 else ''}."})
|
|
143
176
|
|
|
177
|
+
elif action == 'navigate':
|
|
178
|
+
cell_ref = request.get('cellRef', '')
|
|
179
|
+
if not cell_ref:
|
|
180
|
+
return json.dumps({'ok': False, 'error': "navigate requires cellRef"})
|
|
181
|
+
ok = _run_on_main(lambda: navigate_to_cell(xl_app(), cell_ref))
|
|
182
|
+
return json.dumps({'ok': ok, 'output': '' if ok else f"Could not navigate to {cell_ref}"})
|
|
183
|
+
|
|
144
184
|
elif action == 'cleanup':
|
|
185
|
+
_run_on_main(clear_navigate_cursor)
|
|
145
186
|
if cf_tx_id is not None:
|
|
146
187
|
_run_on_main(lambda: cleanup_by_tx(cf_tx_id))
|
|
147
188
|
return json.dumps({'ok': True, 'output': ''})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Navigate Excel to a specific cell — used during diff review.
|
|
2
|
+
|
|
3
|
+
Highlights the cursor cell with a colored border via direct Range
|
|
4
|
+
formatting. Visible even when Excel doesn't have focus (unlike
|
|
5
|
+
.Select() which only shows when Excel is foreground).
|
|
6
|
+
|
|
7
|
+
Thread safety: called via _run_on_main, serialized by s_exec_lock.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from shortcut_xl._log import xl_log
|
|
11
|
+
from shortcut_xl.api.utils.helpers import extract_sheet_and_range
|
|
12
|
+
|
|
13
|
+
# Excel COM constants
|
|
14
|
+
_XL_EDGE_LEFT = 7
|
|
15
|
+
_XL_EDGE_TOP = 8
|
|
16
|
+
_XL_EDGE_BOTTOM = 9
|
|
17
|
+
_XL_EDGE_RIGHT = 10
|
|
18
|
+
_XL_CONTINUOUS = 1
|
|
19
|
+
_XL_LINE_STYLE_NONE = -4142
|
|
20
|
+
_XL_MEDIUM = -4138
|
|
21
|
+
|
|
22
|
+
_CURSOR_BLUE = 0xFF0000 # BGR — distinct from yellow change highlights
|
|
23
|
+
_BORDER_EDGES = (_XL_EDGE_LEFT, _XL_EDGE_TOP, _XL_EDGE_BOTTOM, _XL_EDGE_RIGHT)
|
|
24
|
+
|
|
25
|
+
# Current cursor state — at most one cell highlighted at a time.
|
|
26
|
+
_cursor_ws = None # COM Worksheet of the current cursor cell
|
|
27
|
+
_cursor_address = None # e.g. "B5"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_cell_ref(cell_ref):
|
|
31
|
+
"""Parse a cell reference into (workbook, sheet, address).
|
|
32
|
+
|
|
33
|
+
Supports single cells and ranges:
|
|
34
|
+
"[Book1]Sheet1!A1" → ("Book1", "Sheet1", "A1")
|
|
35
|
+
"Sheet1!A1:E1" → (None, "Sheet1", "A1:E1")
|
|
36
|
+
|
|
37
|
+
Returns (None, None, None) if the reference is invalid.
|
|
38
|
+
"""
|
|
39
|
+
rest = cell_ref
|
|
40
|
+
workbook = None
|
|
41
|
+
|
|
42
|
+
if rest.startswith('['):
|
|
43
|
+
close = rest.find(']')
|
|
44
|
+
if close <= 1: # missing or empty workbook name
|
|
45
|
+
return None, None, None
|
|
46
|
+
workbook = rest[1:close]
|
|
47
|
+
rest = rest[close + 1:]
|
|
48
|
+
|
|
49
|
+
sheet, address = extract_sheet_and_range(rest)
|
|
50
|
+
if not sheet or not address:
|
|
51
|
+
return None, None, None
|
|
52
|
+
|
|
53
|
+
return workbook, sheet, address
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_worksheet(app, workbook, sheet):
|
|
57
|
+
"""Get the COM Worksheet object for the given workbook/sheet names."""
|
|
58
|
+
if workbook:
|
|
59
|
+
return app.Workbooks(workbook).Worksheets(sheet)
|
|
60
|
+
return app.Worksheets(sheet)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _set_border(rng, color, line_style, weight):
|
|
64
|
+
"""Apply border styling to all four edges of a range."""
|
|
65
|
+
for edge in _BORDER_EDGES:
|
|
66
|
+
b = rng.Borders(edge)
|
|
67
|
+
b.LineStyle = line_style
|
|
68
|
+
if line_style != _XL_LINE_STYLE_NONE:
|
|
69
|
+
b.Color = color
|
|
70
|
+
b.Weight = weight
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clear_cursor():
|
|
74
|
+
"""Remove the border from the current cursor cell, if any."""
|
|
75
|
+
global _cursor_ws, _cursor_address
|
|
76
|
+
if _cursor_ws is None or _cursor_address is None:
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
_set_border(_cursor_ws.Range(_cursor_address), 0, _XL_LINE_STYLE_NONE, 0)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
xl_log(f"_clear_cursor({_cursor_address}): {e}")
|
|
82
|
+
_cursor_ws = None
|
|
83
|
+
_cursor_address = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def navigate_to_cell(app, cell_ref):
|
|
87
|
+
"""Highlight the cell with a colored border.
|
|
88
|
+
|
|
89
|
+
*cell_ref* is "[Book1]Sheet1!A1" or "Sheet1!A1".
|
|
90
|
+
Clears the previous cursor border before applying the new one.
|
|
91
|
+
Returns True on success, False on failure.
|
|
92
|
+
"""
|
|
93
|
+
global _cursor_ws, _cursor_address
|
|
94
|
+
|
|
95
|
+
workbook, sheet, address = parse_cell_ref(cell_ref)
|
|
96
|
+
if not sheet or not address:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
_clear_cursor()
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
ws = _resolve_worksheet(app, workbook, sheet)
|
|
103
|
+
rng = ws.Range(address)
|
|
104
|
+
_set_border(rng, _CURSOR_BLUE, _XL_CONTINUOUS, _XL_MEDIUM)
|
|
105
|
+
_cursor_ws = ws
|
|
106
|
+
_cursor_address = address
|
|
107
|
+
return True
|
|
108
|
+
except Exception as e:
|
|
109
|
+
xl_log(f"navigate_to_cell({cell_ref}): {e}")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def clear_navigate_cursor():
|
|
114
|
+
"""Remove the cursor border. Called on approval accept/reject."""
|
|
115
|
+
_clear_cursor()
|
|
@@ -142,8 +142,14 @@ class DirtyTracker:
|
|
|
142
142
|
try:
|
|
143
143
|
after = _SheetSnapshot(ws)
|
|
144
144
|
changes = _diff_snapshots(name, before, after)
|
|
145
|
-
# Enrich with
|
|
145
|
+
# Enrich with workbook name + numberFormat
|
|
146
|
+
try:
|
|
147
|
+
wb_name = ws.Parent.Name
|
|
148
|
+
except Exception:
|
|
149
|
+
wb_name = None
|
|
146
150
|
for entry in changes:
|
|
151
|
+
if wb_name:
|
|
152
|
+
entry['workbook'] = wb_name
|
|
147
153
|
try:
|
|
148
154
|
fmt = ws.Range(entry['address']).NumberFormat
|
|
149
155
|
if fmt and fmt != 'General':
|