本文へ移動
cccskills
無料GitHub で公開

excel-report

Create an auditable Markdown report for any uploaded Microsoft Excel (.xlsx) or CSV files in an Openrind Shell sandbox.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md19.2 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Excel & CSV Reporting

CRITICAL INSTRUCTION FOR CLAUDE:

  • This skill does NOT spawn background processes or background task IDs. There is NO task ID (never call Task Output or wait for skill_run).
  • You (Claude) must directly and immediately execute the Bash command in Step 2 to generate the parsed data and then write the final report.
  • Never claim data was analyzed until the parsing command succeeds.

Core Rules

  • Zero External Dependencies: Standard python3 (built-in zipfile, xml.etree.ElementTree, csv, math, statistics, json, datetime) is used directly.
  • Never probe or search for tools: Do NOT execute which libreoffice, which csvkit, which unzip, or apt list.
  • Never run network package installs: Do NOT run pip install, uv pip install, or attempt downloading external packages. The built-in Python script parses .xlsx OpenXML and .csv natively, offline, and in milliseconds.
  • Bounded input: Reject files over 10 MiB, ZIP expansion over 32 MiB, members over 8 MiB, more than 128 members, 10,000 rows, 256 columns, or 250,000 dense cells per sheet. Do not retry rejected files without splitting them first.
  • Privacy & Security Gate: Strictly prohibit exposing secrets, credentials (tokens, passwords, API keys, private keys, database URLs), and unnecessary personal data (PII, SSNs, credit card numbers). Redact sensitive columns and values ([REDACTED]). Row-level samples must be redacted, and do not dump raw row-level data or full reports in chat when row-level or sensitive records are present.

Step 1: Locate Target Files

Determine the inbox directory and find the target file(s):

if mountpoint -q /sandbox/work 2>/dev/null; then
  INBOX=/sandbox/work/inbox
elif [ -d "/sandbox/inbox" ]; then
  INBOX=/sandbox/inbox
else
  INBOX="."
fi
mkdir -p "$INBOX"

If specific file path(s) were given in the user prompt or skill argument, use those. Otherwise, discover all workbooks dynamically:

find "$INBOX" /sandbox . -maxdepth 3 -type f \( -iname '*.xlsx' -o -iname '*.csv' \) 2>/dev/null | sort -u

Step 2: Run The Self-Contained Parser

Create /tmp/openrind-excel-parse.py and run it against the target file(s) with python3:

cat << 'EOF' > /tmp/openrind-excel-parse.py
import sys, os, zipfile, csv, json, re, math, statistics, datetime, io
from pathlib import Path
import xml.etree.ElementTree as ET
from collections import Counter

MAX_FILE = 10 * 1024 * 1024
MAX_ROWS, MAX_COLS, MAX_CELLS = 10000, 256, 250000
csv.field_size_limit(65536)

def check_file(path):
    if Path(path).stat().st_size > MAX_FILE:
        raise ValueError('Input exceeds the 10 MiB safety limit')

def check_shape(rows, cols):
    if rows > MAX_ROWS or cols > MAX_COLS or rows * cols > MAX_CELLS:
        raise ValueError('Sheet exceeds safe row, column, or cell limits')

def col_letter_to_index(col_str):
    idx = 0
    for char in col_str.upper():
        if 'A' <= char <= 'Z':
            idx = idx * 26 + (ord(char) - ord('A') + 1)
    return idx - 1

def index_to_col_letter(idx):
    res = []
    idx += 1
    while idx > 0:
        idx, rem = divmod(idx - 1, 26)
        res.append(chr(ord('A') + rem))
    return ''.join(reversed(res))

def parse_cell_ref(cell_ref):
    m = re.match(r'([A-Za-z]+)([0-9]+)', cell_ref)
    if m:
        return col_letter_to_index(m.group(1)), int(m.group(2))
    return None, None

def is_date_format_code(code):
    if not code:
        return False
    cleaned = re.sub(r'"[^"]*"', '', code)
    cleaned = re.sub(r'\[(?!\s*[hmsHMS]\s*\])[^\]]*\]', '', cleaned)
    cleaned = re.sub(r'\\.', '', cleaned)
    cleaned = re.sub(r'[_\*].', '', cleaned)
    cl = cleaned.lower()
    if re.search(r'[ydhs]|am/pm|a/p', cl):
        return True
    if re.search(r'(^|[^a-z0-9])m+($|[^a-z0-9])', cl):
        return True
    return False

def format_excel_date(serial, is_1904=False):
    if is_1904:
        base = datetime.datetime(1904, 1, 1)
        dt = base + datetime.timedelta(days=serial)
    else:
        base = datetime.datetime(1899, 12, 31) if serial < 60 else datetime.datetime(1899, 12, 30)
        dt = base + datetime.timedelta(days=serial)
    if dt.microsecond >= 500000:
        dt += datetime.timedelta(seconds=1)
    if serial < 1 and not is_1904:
        return dt.strftime('%H:%M:%S')
    if dt.hour == 0 and dt.minute == 0 and dt.second == 0:
        return dt.strftime('%Y-%m-%d')
    return dt.strftime('%Y-%m-%d %H:%M:%S')

def read_xlsx(file_path):
    check_file(file_path)
    sheets_data = {}
    with zipfile.ZipFile(file_path, 'r') as z:
        members = z.infolist()
        if (len(members) > 128 or sum(m.file_size for m in members) > 32 * 1024 * 1024
                or any(m.file_size > 8 * 1024 * 1024 or m.file_size > max(1, m.compress_size) * 200 for m in members)):
            raise ValueError('Workbook exceeds ZIP expansion safety limits')
        is_1904 = False
        if 'xl/workbook.xml' in z.namelist():
            wb_tree = ET.fromstring(z.read('xl/workbook.xml'))
            wb_pr = wb_tree.find('.//{*}workbookPr')
            if wb_pr is not None and wb_pr.get('date1904') in ('1', 'true', 'True'):
                is_1904 = True

        custom_num_fmts = {}
        cell_xfs_num_fmt_ids = []
        if 'xl/styles.xml' in z.namelist():
            styles_tree = ET.fromstring(z.read('xl/styles.xml'))
            for num_fmt in styles_tree.findall('.//{*}numFmt'):
                fmt_id_str = num_fmt.get('numFmtId')
                fmt_code = num_fmt.get('formatCode')
                if fmt_id_str is not None and fmt_code is not None:
                    try:
                        custom_num_fmts[int(fmt_id_str)] = fmt_code
                    except ValueError:
                        pass
            for xf in styles_tree.findall('.//{*}cellXfs/{*}xf'):
                num_fmt_id_str = xf.get('numFmtId', '0')
                try:
                    cell_xfs_num_fmt_ids.append(int(num_fmt_id_str))
                except ValueError:
                    cell_xfs_num_fmt_ids.append(0)

        builtin_date_fmt_ids = {
            14, 15, 16, 17, 18, 19, 20, 21, 22,
            27, 28, 29, 30, 31, 32, 33, 34, 35, 36,
            45, 46, 47, 50, 51, 52, 53, 54, 55, 56, 57, 58
        }
        date_style_indices = set()
        for idx, fmt_id in enumerate(cell_xfs_num_fmt_ids):
            if fmt_id in builtin_date_fmt_ids:
                date_style_indices.add(idx)
            elif fmt_id in custom_num_fmts and is_date_format_code(custom_num_fmts[fmt_id]):
                date_style_indices.add(idx)

        shared_strings = []
        if 'xl/sharedStrings.xml' in z.namelist():
            tree = ET.fromstring(z.read('xl/sharedStrings.xml'))
            for si in tree.findall('.//{*}si'):
                texts = [t.text for t in si.findall('.//{*}t') if t.text is not None]
                shared_strings.append(''.join(texts))

        rel_map = {}
        if 'xl/_rels/workbook.xml.rels' in z.namelist():
            rels_tree = ET.fromstring(z.read('xl/_rels/workbook.xml.rels'))
            for rel in rels_tree.findall('.//{*}Relationship'):
                r_id = rel.get('Id')
                target = rel.get('Target', '')
                if target.startswith('/'):
                    target = target[1:]
                if not target.startswith('xl/'):
                    target = 'xl/' + target
                rel_map[r_id] = target

        sheets = []
        if 'xl/workbook.xml' in z.namelist():
            wb_tree = ET.fromstring(z.read('xl/workbook.xml'))
            for idx, sheet_el in enumerate(wb_tree.findall('.//{*}sheet')):
                name = sheet_el.get('name') or f'Sheet{idx+1}'
                r_id = (sheet_el.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id')
                        or sheet_el.get('r:id') or sheet_el.get('id'))
                target = rel_map.get(r_id, f'xl/worksheets/sheet{idx+1}.xml')
                sheets.append({'name': name, 'path': target})

        if not sheets:
            for name in z.namelist():
                if name.startswith('xl/worksheets/sheet') and name.endswith('.xml'):
                    sheets.append({'name': Path(name).stem, 'path': name})

        for s in sheets:
            path = s['path']
            if path not in z.namelist():
                alt = 'xl/' + path if not path.startswith('xl/') else path[3:]
                if alt in z.namelist():
                    path = alt
                else:
                    continue

            ws_tree = ET.fromstring(z.read(path))
            rows_data = []
            max_col = 0

            for row_el in ws_tree.findall('.//{*}row'):
                row_dict = {}
                for c_el in row_el.findall('.//{*}c'):
                    ref = c_el.get('r')
                    t = c_el.get('t', 'n')
                    style_attr = c_el.get('s')
                    style_idx = int(style_attr) if style_attr and style_attr.isdigit() else 0
                    is_date_cell = style_idx in date_style_indices
                    val = None

                    if t == 's':
                        v_el = c_el.find('.//{*}v')
                        if v_el is not None and v_el.text:
                            try:
                                s_idx = int(v_el.text)
                                if s_idx < len(shared_strings):
                                    val = shared_strings[s_idx]
                            except ValueError:
                                val = v_el.text
                    elif t == 'inlineStr':
                        texts = [t_el.text for t_el in c_el.findall('.//{*}t') if t_el.text is not None]
                        val = ''.join(texts) if texts else None
                    elif t == 'b':
                        v_el = c_el.find('.//{*}v')
                        val = (v_el.text == '1') if v_el is not None and v_el.text else False
                    elif t == 'd':
                        v_el = c_el.find('.//{*}v')
                        val = v_el.text if v_el is not None else None
                    elif t in ('str', 'e'):
                        v_el = c_el.find('.//{*}v')
                        val = v_el.text if v_el is not None else None
                    else:
                        v_el = c_el.find('.//{*}v')
                        if v_el is not None and v_el.text:
                            raw = v_el.text
                            try:
                                f_val = float(raw)
                                if is_date_cell:
                                    try:
                                        val = format_excel_date(f_val, is_1904)
                                    except Exception:
                                        val = int(f_val) if f_val.is_integer() else f_val
                                else:
                                    val = int(f_val) if f_val.is_integer() else f_val
                            except ValueError:
                                val = raw

                    col_idx = None
                    if ref:
                        col_idx, _ = parse_cell_ref(ref)
                    if col_idx is not None:
                        check_shape(len(rows_data) + 1, col_idx + 1)
                        row_dict[col_idx] = val
                        if col_idx + 1 > max_col:
                            max_col = col_idx + 1
                rows_data.append(row_dict)
                check_shape(len(rows_data), max_col)

            matrix = []
            for r in rows_data:
                row = [r.get(c, None) for c in range(max_col)]
                matrix.append(row)

            sheets_data[s['name']] = matrix

    return sheets_data

def read_csv(file_path):
    check_file(file_path)
    encodings = ['utf-8-sig', 'utf-8', 'latin-1', 'cp1252']
    raw_bytes = Path(file_path).read_bytes()
    text = None
    for enc in encodings:
        try:
            text = raw_bytes.decode(enc)
            break
        except UnicodeDecodeError:
            continue
    if text is None:
        text = raw_bytes.decode('utf-8', errors='replace')
    try:
        dialect = csv.Sniffer().sniff(text[:4096])
        delimiter = dialect.delimiter
    except Exception:
        delimiter = ','
    reader = csv.reader(io.StringIO(text), delimiter=delimiter)
    matrix = []
    for row in reader:
        check_shape(len(matrix) + 1, len(row))
        converted = []
        for cell in row:
            val = cell.strip()
            converted.append(val if val != '' else None)
        matrix.append(converted)
    return {'CSV Data': matrix}

def is_sensitive_name(name):
    n = str(name).lower()
    patterns = ['password', 'secret', 'token', 'key', 'ssn', 'auth', 'credential', 'credit_card', 'card_number', 'api_key', 'private', 'bearer', 'cvv', 'pin']
    return any(p in n for p in patterns)

def analyze_matrix(matrix):
    if not matrix:
        return {'total_rows': 0, 'total_cols': 0, 'duplicate_rows': 0, 'headers': [], 'sample': [], 'columns': {}}
    raw_headers = [str(c) if c is not None and str(c).strip() != '' else f'Column_{i+1}' for i, c in enumerate(matrix[0])]
    data_rows = matrix[1:]
    total_rows = len(data_rows)
    total_cols = len(raw_headers)
    row_tuples = [tuple(r) for r in data_rows]
    dup_count = len(row_tuples) - len(set(row_tuples)) if total_rows > 0 else 0

    seen_header_counts = {}
    unique_keys = []
    for h in raw_headers:
        if h in seen_header_counts:
            seen_header_counts[h] += 1
            unique_keys.append(f"{h}_{seen_header_counts[h]}")
        else:
            seen_header_counts[h] = 1
            unique_keys.append(h)

    # Default-deny value disclosure: arbitrary notes, identifiers, and numeric
    # account/card values cannot be reliably classified using header names.
    sensitive_cols = set(range(len(raw_headers)))
    redacted_sample = []
    for r in data_rows[:5]:
        redacted_sample.append([('[REDACTED]' if c_idx in sensitive_cols else (r[c_idx] if c_idx < len(r) else None)) for c_idx in range(len(raw_headers))])
    columns_info = {}
    for col_idx, (col_name, col_key) in enumerate(zip(raw_headers, unique_keys)):
        values = [r[col_idx] if col_idx < len(r) else None for r in data_rows]
        non_null_values = [v for v in values if v is not None and v != '']
        null_count = total_rows - len(non_null_values)
        null_pct = (null_count / total_rows * 100) if total_rows > 0 else 0.0
        unique_count = len(set(non_null_values))
        numeric_vals = []
        for v in non_null_values:
            if isinstance(v, (int, float)) and not isinstance(v, bool):
                numeric_vals.append(float(v))
            elif isinstance(v, str):
                try:
                    numeric_vals.append(float(v.strip().replace(',', '')))
                except ValueError:
                    pass
        is_numeric = len(numeric_vals) == len(non_null_values) and len(non_null_values) > 0
        col_info = {
            'header': col_name,
            'col_index': col_idx,
            'col_letter': index_to_col_letter(col_idx),
            'dtype': 'numeric' if is_numeric else 'text',
            'missing_count': null_count,
            'missing_pct': round(null_pct, 2),
            'unique_count': unique_count
        }
        if col_idx in sensitive_cols:
            col_info['values_withheld'] = True
        elif is_numeric and numeric_vals:
            numeric_vals.sort()
            n = len(numeric_vals)
            mean_val = statistics.mean(numeric_vals)
            std_val = statistics.stdev(numeric_vals) if n > 1 else 0.0
            def percentile(s, p):
                idx = (len(s) - 1) * p
                l, u = int(math.floor(idx)), int(math.ceil(idx))
                return s[l] if l == u else s[l] * (u - idx) + s[u] * (idx - l)
            q1, q3 = percentile(numeric_vals, 0.25), percentile(numeric_vals, 0.75)
            iqr = q3 - q1
            outliers = [v for v in numeric_vals if v < (q1 - 1.5*iqr) or v > (q3 + 1.5*iqr)]
            col_info['stats'] = {
                'count': n, 'sum': round(sum(numeric_vals), 2), 'mean': round(mean_val, 2), 'std': round(std_val, 2),
                'min': round(numeric_vals[0], 2), 'q1': round(q1, 2), 'median': round(statistics.median(numeric_vals), 2),
                'q3': round(q3, 2), 'max': round(numeric_vals[-1], 2), 'iqr': round(iqr, 2), 'outlier_count': len(outliers)
            }
        else:
            if col_idx in sensitive_cols:
                col_info['top_values'] = [{'value': '[REDACTED]', 'count': len(non_null_values), 'pct': 100.0}]
            else:
                counts = Counter([str(v) for v in non_null_values]).most_common(5)
                col_info['top_values'] = [{'value': k, 'count': c, 'pct': round(c/len(non_null_values)*100, 1) if non_null_values else 0} for k, c in counts]
        columns_info[col_key] = col_info
    return {'total_rows': total_rows, 'total_cols': total_cols, 'duplicate_rows': dup_count, 'headers': raw_headers, 'sample': redacted_sample, 'columns': columns_info}

def process_file(p):
    path = Path(p)
    if not path.exists(): return None
    sfx = path.suffix.lower()
    if sfx in ('.xlsx', '.xlsm', '.xltx'):
        sheets = read_xlsx(path); fmt = 'Excel Workbook'
    elif sfx in ('.csv', '.tsv', '.txt'):
        sheets = read_csv(path); fmt = 'CSV'
    else: return None
    return {'filename': path.name, 'path': str(path.absolute()), 'size_bytes': path.stat().st_size, 'format': fmt, 'sheets': {k: {'stats': analyze_matrix(v)} for k, v in sheets.items()}}

target_files = sys.argv[1:]
results = []
for f in target_files:
    res = process_file(f)
    if res is not None:
        results.append(res)
print(json.dumps(results, indent=2))
EOF

Run against the target file(s):

python3 /tmp/openrind-excel-parse.py "<path_to_file1>" ["<path_to_file2>" ...] > /tmp/parsed-excel.json

Step 3: Write The Report Contract

Read /tmp/parsed-excel.json and generate $INBOX/analysis-report.md with:

  1. Executive Summary — concise, factual overview of the files analyzed and critical findings.
  2. Data Scope — table showing file names, worksheet names, dimensions (rows × columns), and duplicate rows.
  3. Key Metrics And Statistics — tables with counts, means, medians, std dev, min, max, IQR outliers, and missing counts.
  4. Detailed Findings — column diagnostics, data distributions, categorical frequencies, and redacted/opt-in data previews.
  5. Data-Quality Risks — explicit itemization of missing values, duplicate records, and outliers tied to specific workbooks, sheets, and columns.
  6. Next Steps — actionable recommendations and investigative questions for further analysis.

Tie each finding to a named workbook, sheet, and column. Distinguish observed facts from inference. Ensure secrets, credentials, and unnecessary personal data are never printed in plaintext; redact sensitive columns and values. After writing the file, tell the user its exact path. Present an executive summary and high-level findings in chat; do not require or dump the full report in chat when it contains row-level data or sensitive details.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

Create new OpenCode agents with a gpt-5.2-codex default.

日本語の概要は準備中です。原文の説明を表示しています。

openrind/openrind-shell32026年10月10日 更新

Guide users through browser automation setup using Chrome DevTools MCP only. Use when the user asks to set up browser automation, Chrome DevTools MCP, browser MCP, or runs the browser-setup command.

日本語の概要は準備中です。原文の説明を表示しています。

openrind/openrind-shell32026年10月10日 更新

Manages Cargo.lock file updates and resolves --locked flag issues in CI/CD. Triggers when user mentions: - "cargo test --locked failed" - "cannot update the lock file" - "Cargo.lock is out of date" - "PR failed with --locked error" - "fix Cargo.lock"

日本語の概要は準備中です。原文の説明を表示しています。

openrind/openrind-shell32026年10月10日 更新

Create OpenCode custom commands for repeatable tasks.

日本語の概要は準備中です。原文の説明を表示しています。

openrind/openrind-shell32026年10月10日 更新

Guide users through the get started setup and Chrome DevTools demo.

日本語の概要は準備中です。原文の説明を表示しています。

openrind/openrind-shell32026年10月10日 更新

Bridge between Openrind Desktop UI and OpenCode runtime

日本語の概要は準備中です。原文の説明を表示しています。

openrind/openrind-shell32026年10月10日 更新

openrind のスキルをすべて見る

このスキルの問題を報告する